Skip to main content

fraiseql_cli/commands/
init.rs

1//! `fraiseql init` - Interactive project scaffolder
2//!
3//! Creates a new FraiseQL project with the correct directory structure,
4//! configuration files, and authoring skeleton in the chosen language.
5
6use std::{
7    fmt, fs,
8    path::{Path, PathBuf},
9    process::Command,
10    str::FromStr,
11};
12
13use anyhow::{Context, Result};
14use tracing::info;
15
16/// Supported authoring languages
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Language {
19    /// Python authoring (default)
20    Python,
21    /// TypeScript authoring
22    TypeScript,
23    /// Rust authoring
24    Rust,
25    /// Java authoring
26    Java,
27    /// Kotlin authoring
28    Kotlin,
29    /// Go authoring
30    Go,
31    /// C# authoring
32    CSharp,
33    /// Swift authoring
34    Swift,
35    /// Scala authoring
36    Scala,
37}
38
39impl fmt::Display for Language {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::Python => write!(f, "python"),
43            Self::TypeScript => write!(f, "typescript"),
44            Self::Rust => write!(f, "rust"),
45            Self::Java => write!(f, "java"),
46            Self::Kotlin => write!(f, "kotlin"),
47            Self::Go => write!(f, "go"),
48            Self::CSharp => write!(f, "csharp"),
49            Self::Swift => write!(f, "swift"),
50            Self::Scala => write!(f, "scala"),
51        }
52    }
53}
54
55impl Language {
56    /// Map file extension to language (for `fraiseql extract` auto-detection).
57    pub fn from_extension(ext: &str) -> Option<Self> {
58        match ext {
59            "py" => Some(Self::Python),
60            "ts" | "tsx" => Some(Self::TypeScript),
61            "rs" => Some(Self::Rust),
62            "java" => Some(Self::Java),
63            "kt" | "kts" => Some(Self::Kotlin),
64            "go" => Some(Self::Go),
65            "cs" => Some(Self::CSharp),
66            "swift" => Some(Self::Swift),
67            "scala" | "sc" => Some(Self::Scala),
68            _ => None,
69        }
70    }
71}
72
73impl FromStr for Language {
74    type Err = String;
75
76    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
77        match s.to_lowercase().as_str() {
78            "python" | "py" => Ok(Self::Python),
79            "typescript" | "ts" => Ok(Self::TypeScript),
80            "rust" | "rs" => Ok(Self::Rust),
81            "java" | "jav" => Ok(Self::Java),
82            "kotlin" | "kt" => Ok(Self::Kotlin),
83            "go" | "golang" => Ok(Self::Go),
84            "csharp" | "c#" | "cs" => Ok(Self::CSharp),
85            "swift" => Ok(Self::Swift),
86            "scala" | "sc" => Ok(Self::Scala),
87            other => Err(format!(
88                "Unknown language: {other}. Choose: python, typescript, rust, java, kotlin, go, csharp, swift, scala"
89            )),
90        }
91    }
92}
93
94/// Supported databases
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Database {
97    /// PostgreSQL (primary)
98    Postgres,
99    /// MySQL
100    Mysql,
101    /// SQLite
102    Sqlite,
103    /// SQL Server
104    SqlServer,
105}
106
107impl fmt::Display for Database {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            Self::Postgres => write!(f, "postgres"),
111            Self::Mysql => write!(f, "mysql"),
112            Self::Sqlite => write!(f, "sqlite"),
113            Self::SqlServer => write!(f, "sqlserver"),
114        }
115    }
116}
117
118impl FromStr for Database {
119    type Err = String;
120
121    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
122        match s.to_lowercase().as_str() {
123            "postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
124            "mysql" => Ok(Self::Mysql),
125            "sqlite" => Ok(Self::Sqlite),
126            "sqlserver" | "mssql" => Ok(Self::SqlServer),
127            other => Err(format!(
128                "Unknown database: {other}. Choose: postgres, mysql, sqlite, sqlserver"
129            )),
130        }
131    }
132}
133
134/// Database target string for fraiseql.toml
135impl Database {
136    const fn toml_target(self) -> &'static str {
137        match self {
138            Self::Postgres => "postgresql",
139            Self::Mysql => "mysql",
140            Self::Sqlite => "sqlite",
141            Self::SqlServer => "sqlserver",
142        }
143    }
144
145    fn default_url(self, project_name: &str) -> String {
146        match self {
147            Self::Postgres => format!("postgresql://localhost/{project_name}"),
148            Self::Mysql => format!("mysql://localhost/{project_name}"),
149            Self::Sqlite => format!("{project_name}.db"),
150            Self::SqlServer => format!("mssql://localhost/{project_name}"),
151        }
152    }
153}
154
155/// Project size determines directory granularity
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ProjectSize {
158    /// Single `schema.sql` file
159    Xs,
160    /// Flat numbered directories (01_write, 02_read, 03_functions)
161    S,
162    /// Per-entity subdirectories under each numbered directory
163    M,
164}
165
166impl FromStr for ProjectSize {
167    type Err = String;
168
169    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
170        match s.to_lowercase().as_str() {
171            "xs" => Ok(Self::Xs),
172            "s" => Ok(Self::S),
173            "m" => Ok(Self::M),
174            other => Err(format!("Unknown size: {other}. Choose: xs, s, m")),
175        }
176    }
177}
178
179/// Configuration for the init command
180pub struct InitConfig {
181    /// Name of the project (used as directory name)
182    pub project_name: String,
183    /// Authoring language for the schema skeleton
184    pub language:     Language,
185    /// Target database engine
186    pub database:     Database,
187    /// Project size (directory granularity)
188    pub size:         ProjectSize,
189    /// Skip git initialization
190    pub no_git:       bool,
191}
192
193/// Run the init command
194pub fn run(config: &InitConfig) -> Result<()> {
195    let project_dir = PathBuf::from(&config.project_name);
196
197    if project_dir.exists() {
198        anyhow::bail!(
199            "Directory '{}' already exists. Choose a different name or remove it first.",
200            config.project_name
201        );
202    }
203
204    info!("Creating project: {}", config.project_name);
205    println!("Creating FraiseQL project: {}", config.project_name);
206
207    // Create root directory
208    fs::create_dir_all(&project_dir)
209        .context(format!("Failed to create directory: {}", config.project_name))?;
210
211    // Create .gitignore
212    create_gitignore(&project_dir)?;
213
214    // Create fraiseql.toml
215    create_toml_config(&project_dir, config)?;
216
217    // Create schema.json
218    create_schema_json(&project_dir)?;
219
220    // Create database directory structure
221    create_db_structure(&project_dir, config)?;
222
223    // Create language-specific authoring skeleton
224    create_authoring_skeleton(&project_dir, config)?;
225
226    // Initialize git repository
227    if !config.no_git {
228        init_git(&project_dir)?;
229    }
230
231    println!();
232    println!("Project created at ./{}", config.project_name);
233    println!();
234    println!("Next steps:");
235    println!("  cd {}", config.project_name);
236    println!("  fraiseql compile fraiseql.toml");
237    if !config.no_git {
238        println!("  git add -A && git commit -m \"Initial FraiseQL project\"");
239    }
240    println!();
241
242    Ok(())
243}
244
245fn create_gitignore(project_dir: &Path) -> Result<()> {
246    let content = "\
247# FraiseQL compiled output
248schema.compiled.json
249
250# Rust
251target/
252
253# Python
254__pycache__/
255*.pyc
256.venv/
257
258# TypeScript / Node
259node_modules/
260dist/
261
262# IDE
263.idea/
264.vscode/
265*.swp
266*.swo
267
268# OS
269.DS_Store
270Thumbs.db
271
272# Environment
273.env
274.env.local
275";
276    fs::write(project_dir.join(".gitignore"), content).context("Failed to create .gitignore")?;
277    info!("Created .gitignore");
278    Ok(())
279}
280
281fn create_toml_config(project_dir: &Path, config: &InitConfig) -> Result<()> {
282    let db_url = config.database.default_url(&config.project_name);
283    let db_target = config.database.toml_target();
284
285    let content = format!(
286        r#"[project]
287name = "{name}"
288version = "0.1.0"
289description = "A FraiseQL project"
290
291[fraiseql]
292schema_file = "schema.json"
293output_file = "schema.compiled.json"
294
295[fraiseql.security.rate_limiting]
296enabled = true
297auth_start_max_requests = 100
298auth_start_window_secs = 60
299
300[fraiseql.security.audit_logging]
301enabled = true
302log_level = "info"
303
304[schema]
305name = "{name}"
306version = "1.0.0"
307database_target = "{db_target}"
308
309[database]
310url = "{db_url}"
311pool_size = 10
312ssl_mode = "prefer"
313timeout_seconds = 30
314"#,
315        name = config.project_name,
316    );
317
318    fs::write(project_dir.join("fraiseql.toml"), content)
319        .context("Failed to create fraiseql.toml")?;
320    info!("Created fraiseql.toml");
321    Ok(())
322}
323
324fn create_schema_json(project_dir: &Path) -> Result<()> {
325    // IntermediateSchema format: arrays of typed objects
326    // Blog project: Author, Post, Comment, Tag
327    let schema = serde_json::json!({
328        "version": "2.0.0",
329        "types": [
330            {
331                "name": "Author",
332                "description": "Blog author",
333                "fields": [
334                    { "name": "pk", "type": "Int", "nullable": false, "description": "Internal primary key" },
335                    { "name": "id", "type": "ID", "nullable": false, "description": "Public UUID" },
336                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
337                    { "name": "name", "type": "String", "nullable": false },
338                    { "name": "email", "type": "String", "nullable": false },
339                    { "name": "bio", "type": "String", "nullable": true },
340                    { "name": "created_at", "type": "DateTime", "nullable": false },
341                    { "name": "updated_at", "type": "DateTime", "nullable": false }
342                ]
343            },
344            {
345                "name": "Post",
346                "description": "Blog post",
347                "fields": [
348                    { "name": "pk", "type": "Int", "nullable": false },
349                    { "name": "id", "type": "ID", "nullable": false },
350                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
351                    { "name": "title", "type": "String", "nullable": false },
352                    { "name": "body", "type": "String", "nullable": false },
353                    { "name": "published", "type": "Boolean", "nullable": false },
354                    { "name": "author_id", "type": "ID", "nullable": false },
355                    { "name": "created_at", "type": "DateTime", "nullable": false },
356                    { "name": "updated_at", "type": "DateTime", "nullable": false }
357                ]
358            },
359            {
360                "name": "Comment",
361                "description": "Comment on a blog post",
362                "fields": [
363                    { "name": "pk", "type": "Int", "nullable": false },
364                    { "name": "id", "type": "ID", "nullable": false },
365                    { "name": "body", "type": "String", "nullable": false },
366                    { "name": "author_name", "type": "String", "nullable": false },
367                    { "name": "post_id", "type": "ID", "nullable": false },
368                    { "name": "created_at", "type": "DateTime", "nullable": false }
369                ]
370            },
371            {
372                "name": "Tag",
373                "description": "Categorization tag for posts",
374                "fields": [
375                    { "name": "pk", "type": "Int", "nullable": false },
376                    { "name": "id", "type": "ID", "nullable": false },
377                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
378                    { "name": "name", "type": "String", "nullable": false }
379                ]
380            }
381        ],
382        "queries": [
383            {
384                "name": "posts",
385                "return_type": "Post",
386                "return_array": true,
387                "sql_source": "v_post",
388                "description": "List all published posts"
389            },
390            {
391                "name": "post",
392                "return_type": "Post",
393                "return_array": false,
394                "sql_source": "v_post",
395                "args": [{ "name": "id", "type": "ID", "required": true }]
396            },
397            {
398                "name": "authors",
399                "return_type": "Author",
400                "return_array": true,
401                "sql_source": "v_author"
402            },
403            {
404                "name": "author",
405                "return_type": "Author",
406                "return_array": false,
407                "sql_source": "v_author",
408                "args": [{ "name": "id", "type": "ID", "required": true }]
409            },
410            {
411                "name": "tags",
412                "return_type": "Tag",
413                "return_array": true,
414                "sql_source": "v_tag"
415            }
416        ],
417        "mutations": [],
418        "enums": [],
419        "input_types": [],
420        "interfaces": [],
421        "unions": [],
422        "subscriptions": []
423    });
424
425    let content = serde_json::to_string_pretty(&schema).context("Failed to serialize schema")?;
426    fs::write(project_dir.join("schema.json"), content).context("Failed to create schema.json")?;
427    info!("Created schema.json");
428    Ok(())
429}
430
431fn create_db_structure(project_dir: &Path, config: &InitConfig) -> Result<()> {
432    match config.size {
433        ProjectSize::Xs => create_db_xs(project_dir, config),
434        ProjectSize::S => create_db_s(project_dir, config),
435        ProjectSize::M => create_db_m(project_dir, config),
436    }
437}
438
439fn create_db_xs(project_dir: &Path, config: &InitConfig) -> Result<()> {
440    let db_dir = project_dir.join("db").join("0_schema");
441    fs::create_dir_all(&db_dir).context("Failed to create db/0_schema")?;
442
443    let content = generate_single_schema_sql(config.database);
444    fs::write(db_dir.join("schema.sql"), content).context("Failed to create schema.sql")?;
445    info!("Created db/0_schema/schema.sql (xs layout)");
446    Ok(())
447}
448
449fn create_db_s(project_dir: &Path, config: &InitConfig) -> Result<()> {
450    let schema_dir = project_dir.join("db").join("0_schema");
451    let write_dir = schema_dir.join("01_write");
452    let read_dir = schema_dir.join("02_read");
453    let functions_dir = schema_dir.join("03_functions");
454
455    fs::create_dir_all(&write_dir).context("Failed to create 01_write")?;
456    fs::create_dir_all(&read_dir).context("Failed to create 02_read")?;
457    fs::create_dir_all(&functions_dir).context("Failed to create 03_functions")?;
458
459    // Blog entities: author, post, comment, tag (ordered by dependency)
460    let entities = ["author", "post", "comment", "tag"];
461    for (i, entity) in entities.iter().enumerate() {
462        let n = i + 1;
463        let (table_sql, view_sql, fn_sql) = generate_blog_entity_sql(config.database, entity);
464        fs::write(write_dir.join(format!("01{n}_tb_{entity}.sql")), table_sql)
465            .context(format!("Failed to create tb_{entity}.sql"))?;
466        if !view_sql.is_empty() {
467            fs::write(read_dir.join(format!("02{n}_v_{entity}.sql")), view_sql)
468                .context(format!("Failed to create v_{entity}.sql"))?;
469        }
470        if !fn_sql.is_empty() {
471            fs::write(functions_dir.join(format!("03{n}_fn_{entity}_crud.sql")), fn_sql)
472                .context(format!("Failed to create fn_{entity}_crud.sql"))?;
473        }
474    }
475
476    info!("Created db/0_schema/ (s layout)");
477    Ok(())
478}
479
480fn create_db_m(project_dir: &Path, config: &InitConfig) -> Result<()> {
481    let schema_dir = project_dir.join("db").join("0_schema");
482
483    let entities = ["author", "post", "comment", "tag"];
484    for entity in &entities {
485        let write_dir = schema_dir.join("01_write").join(entity);
486        let read_dir = schema_dir.join("02_read").join(entity);
487        let functions_dir = schema_dir.join("03_functions").join(entity);
488
489        fs::create_dir_all(&write_dir).context(format!("Failed to create 01_write/{entity}"))?;
490        fs::create_dir_all(&read_dir).context(format!("Failed to create 02_read/{entity}"))?;
491        fs::create_dir_all(&functions_dir)
492            .context(format!("Failed to create 03_functions/{entity}"))?;
493
494        let (table_sql, view_sql, fn_sql) = generate_blog_entity_sql(config.database, entity);
495        fs::write(write_dir.join(format!("tb_{entity}.sql")), table_sql)
496            .context(format!("Failed to create tb_{entity}.sql"))?;
497        if !view_sql.is_empty() {
498            fs::write(read_dir.join(format!("v_{entity}.sql")), view_sql)
499                .context(format!("Failed to create v_{entity}.sql"))?;
500        }
501        if !fn_sql.is_empty() {
502            fs::write(functions_dir.join(format!("fn_{entity}_crud.sql")), fn_sql)
503                .context(format!("Failed to create fn_{entity}_crud.sql"))?;
504        }
505    }
506
507    info!("Created db/0_schema/ (m layout)");
508    Ok(())
509}
510
511/// Generate a single-file schema for XS size (blog project)
512fn generate_single_schema_sql(database: Database) -> String {
513    match database {
514        Database::Postgres => BLOG_SCHEMA_POSTGRES.to_string(),
515        Database::Mysql => BLOG_SCHEMA_MYSQL.to_string(),
516        Database::Sqlite => BLOG_SCHEMA_SQLITE.to_string(),
517        Database::SqlServer => BLOG_SCHEMA_SQLSERVER.to_string(),
518    }
519}
520
521const BLOG_SCHEMA_POSTGRES: &str = "\
522-- FraiseQL Blog Schema
523-- Trinity pattern: pk (internal), id (public UUID), identifier (URL slug)
524
525-- Authors
526CREATE TABLE IF NOT EXISTS tb_author (
527    pk_author   SERIAL PRIMARY KEY,
528    id          UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
529    identifier  TEXT NOT NULL UNIQUE,
530    name        TEXT NOT NULL,
531    email       TEXT NOT NULL UNIQUE,
532    bio         TEXT,
533    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
534    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
535);
536
537CREATE INDEX IF NOT EXISTS idx_tb_author_email ON tb_author (email);
538
539CREATE OR REPLACE VIEW v_author AS
540SELECT pk_author, id, identifier, name, email, bio, created_at, updated_at
541FROM tb_author;
542
543-- Posts
544CREATE TABLE IF NOT EXISTS tb_post (
545    pk_post     SERIAL PRIMARY KEY,
546    id          UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
547    identifier  TEXT NOT NULL UNIQUE,
548    title       TEXT NOT NULL,
549    body        TEXT NOT NULL,
550    published   BOOLEAN NOT NULL DEFAULT false,
551    author_id   UUID NOT NULL REFERENCES tb_author(id),
552    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
553    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
554);
555
556CREATE INDEX IF NOT EXISTS idx_tb_post_author ON tb_post (author_id);
557CREATE INDEX IF NOT EXISTS idx_tb_post_published ON tb_post (published) WHERE published = true;
558
559CREATE OR REPLACE VIEW v_post AS
560SELECT pk_post, id, identifier, title, body, published, author_id, created_at, updated_at
561FROM tb_post;
562
563-- Comments
564CREATE TABLE IF NOT EXISTS tb_comment (
565    pk_comment  SERIAL PRIMARY KEY,
566    id          UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
567    body        TEXT NOT NULL,
568    author_name TEXT NOT NULL,
569    post_id     UUID NOT NULL REFERENCES tb_post(id) ON DELETE CASCADE,
570    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
571);
572
573CREATE INDEX IF NOT EXISTS idx_tb_comment_post ON tb_comment (post_id);
574
575CREATE OR REPLACE VIEW v_comment AS
576SELECT pk_comment, id, body, author_name, post_id, created_at
577FROM tb_comment;
578
579-- Tags
580CREATE TABLE IF NOT EXISTS tb_tag (
581    pk_tag      SERIAL PRIMARY KEY,
582    id          UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
583    identifier  TEXT NOT NULL UNIQUE,
584    name        TEXT NOT NULL UNIQUE
585);
586
587CREATE OR REPLACE VIEW v_tag AS
588SELECT pk_tag, id, identifier, name
589FROM tb_tag;
590
591-- Post-Tag junction
592CREATE TABLE IF NOT EXISTS tb_post_tag (
593    post_id UUID NOT NULL REFERENCES tb_post(id) ON DELETE CASCADE,
594    tag_id  UUID NOT NULL REFERENCES tb_tag(id) ON DELETE CASCADE,
595    PRIMARY KEY (post_id, tag_id)
596);
597";
598
599const BLOG_SCHEMA_MYSQL: &str = "\
600-- FraiseQL Blog Schema
601-- Trinity pattern: pk (internal), id (public UUID), identifier (URL slug)
602
603CREATE TABLE IF NOT EXISTS tb_author (
604    pk_author   INT AUTO_INCREMENT PRIMARY KEY,
605    id          CHAR(36) NOT NULL DEFAULT (UUID()) UNIQUE,
606    identifier  VARCHAR(255) NOT NULL UNIQUE,
607    name        VARCHAR(255) NOT NULL,
608    email       VARCHAR(255) NOT NULL UNIQUE,
609    bio         TEXT,
610    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
611    updated_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
612    INDEX idx_tb_author_email (email)
613);
614
615CREATE OR REPLACE VIEW v_author AS
616SELECT pk_author, id, identifier, name, email, bio, created_at, updated_at
617FROM tb_author;
618
619CREATE TABLE IF NOT EXISTS tb_post (
620    pk_post     INT AUTO_INCREMENT PRIMARY KEY,
621    id          CHAR(36) NOT NULL DEFAULT (UUID()) UNIQUE,
622    identifier  VARCHAR(255) NOT NULL UNIQUE,
623    title       VARCHAR(500) NOT NULL,
624    body        LONGTEXT NOT NULL,
625    published   BOOLEAN NOT NULL DEFAULT false,
626    author_id   CHAR(36) NOT NULL,
627    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
628    updated_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
629    INDEX idx_tb_post_author (author_id),
630    INDEX idx_tb_post_published (published)
631);
632
633CREATE OR REPLACE VIEW v_post AS
634SELECT pk_post, id, identifier, title, body, published, author_id, created_at, updated_at
635FROM tb_post;
636
637CREATE TABLE IF NOT EXISTS tb_comment (
638    pk_comment  INT AUTO_INCREMENT PRIMARY KEY,
639    id          CHAR(36) NOT NULL DEFAULT (UUID()) UNIQUE,
640    body        TEXT NOT NULL,
641    author_name VARCHAR(255) NOT NULL,
642    post_id     CHAR(36) NOT NULL,
643    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
644    INDEX idx_tb_comment_post (post_id)
645);
646
647CREATE OR REPLACE VIEW v_comment AS
648SELECT pk_comment, id, body, author_name, post_id, created_at
649FROM tb_comment;
650
651CREATE TABLE IF NOT EXISTS tb_tag (
652    pk_tag      INT AUTO_INCREMENT PRIMARY KEY,
653    id          CHAR(36) NOT NULL DEFAULT (UUID()) UNIQUE,
654    identifier  VARCHAR(255) NOT NULL UNIQUE,
655    name        VARCHAR(255) NOT NULL UNIQUE
656);
657
658CREATE OR REPLACE VIEW v_tag AS
659SELECT pk_tag, id, identifier, name
660FROM tb_tag;
661";
662
663const BLOG_SCHEMA_SQLITE: &str = "\
664-- FraiseQL Blog Schema
665-- Trinity pattern: pk (internal), id (public UUID), identifier (URL slug)
666
667CREATE TABLE IF NOT EXISTS tb_author (
668    pk_author   INTEGER PRIMARY KEY AUTOINCREMENT,
669    id          TEXT NOT NULL UNIQUE,
670    identifier  TEXT NOT NULL UNIQUE,
671    name        TEXT NOT NULL,
672    email       TEXT NOT NULL UNIQUE,
673    bio         TEXT,
674    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
675    updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
676);
677
678CREATE VIEW IF NOT EXISTS v_author AS
679SELECT pk_author, id, identifier, name, email, bio, created_at, updated_at
680FROM tb_author;
681
682CREATE TABLE IF NOT EXISTS tb_post (
683    pk_post     INTEGER PRIMARY KEY AUTOINCREMENT,
684    id          TEXT NOT NULL UNIQUE,
685    identifier  TEXT NOT NULL UNIQUE,
686    title       TEXT NOT NULL,
687    body        TEXT NOT NULL,
688    published   INTEGER NOT NULL DEFAULT 0,
689    author_id   TEXT NOT NULL REFERENCES tb_author(id),
690    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
691    updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
692);
693
694CREATE INDEX IF NOT EXISTS idx_tb_post_author ON tb_post (author_id);
695
696CREATE VIEW IF NOT EXISTS v_post AS
697SELECT pk_post, id, identifier, title, body, published, author_id, created_at, updated_at
698FROM tb_post;
699
700CREATE TABLE IF NOT EXISTS tb_comment (
701    pk_comment  INTEGER PRIMARY KEY AUTOINCREMENT,
702    id          TEXT NOT NULL UNIQUE,
703    body        TEXT NOT NULL,
704    author_name TEXT NOT NULL,
705    post_id     TEXT NOT NULL REFERENCES tb_post(id) ON DELETE CASCADE,
706    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
707);
708
709CREATE INDEX IF NOT EXISTS idx_tb_comment_post ON tb_comment (post_id);
710
711CREATE VIEW IF NOT EXISTS v_comment AS
712SELECT pk_comment, id, body, author_name, post_id, created_at
713FROM tb_comment;
714
715CREATE TABLE IF NOT EXISTS tb_tag (
716    pk_tag      INTEGER PRIMARY KEY AUTOINCREMENT,
717    id          TEXT NOT NULL UNIQUE,
718    identifier  TEXT NOT NULL UNIQUE,
719    name        TEXT NOT NULL UNIQUE
720);
721
722CREATE VIEW IF NOT EXISTS v_tag AS
723SELECT pk_tag, id, identifier, name
724FROM tb_tag;
725";
726
727const BLOG_SCHEMA_SQLSERVER: &str = "\
728-- FraiseQL Blog Schema
729-- Trinity pattern: pk (internal), id (public UUID), identifier (URL slug)
730
731IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='tb_author' AND xtype='U')
732CREATE TABLE tb_author (
733    pk_author   INT IDENTITY(1,1) PRIMARY KEY,
734    id          UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() UNIQUE,
735    identifier  NVARCHAR(255) NOT NULL UNIQUE,
736    name        NVARCHAR(255) NOT NULL,
737    email       NVARCHAR(255) NOT NULL UNIQUE,
738    bio         NVARCHAR(MAX),
739    created_at  DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
740    updated_at  DATETIME2 NOT NULL DEFAULT GETUTCDATE()
741);
742GO
743
744CREATE OR ALTER VIEW v_author AS
745SELECT pk_author, id, identifier, name, email, bio, created_at, updated_at
746FROM tb_author;
747GO
748
749IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='tb_post' AND xtype='U')
750CREATE TABLE tb_post (
751    pk_post     INT IDENTITY(1,1) PRIMARY KEY,
752    id          UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() UNIQUE,
753    identifier  NVARCHAR(255) NOT NULL UNIQUE,
754    title       NVARCHAR(500) NOT NULL,
755    body        NVARCHAR(MAX) NOT NULL,
756    published   BIT NOT NULL DEFAULT 0,
757    author_id   UNIQUEIDENTIFIER NOT NULL,
758    created_at  DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
759    updated_at  DATETIME2 NOT NULL DEFAULT GETUTCDATE()
760);
761GO
762
763CREATE OR ALTER VIEW v_post AS
764SELECT pk_post, id, identifier, title, body, published, author_id, created_at, updated_at
765FROM tb_post;
766GO
767
768IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='tb_comment' AND xtype='U')
769CREATE TABLE tb_comment (
770    pk_comment  INT IDENTITY(1,1) PRIMARY KEY,
771    id          UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() UNIQUE,
772    body        NVARCHAR(MAX) NOT NULL,
773    author_name NVARCHAR(255) NOT NULL,
774    post_id     UNIQUEIDENTIFIER NOT NULL,
775    created_at  DATETIME2 NOT NULL DEFAULT GETUTCDATE()
776);
777GO
778
779CREATE OR ALTER VIEW v_comment AS
780SELECT pk_comment, id, body, author_name, post_id, created_at
781FROM tb_comment;
782GO
783
784IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='tb_tag' AND xtype='U')
785CREATE TABLE tb_tag (
786    pk_tag      INT IDENTITY(1,1) PRIMARY KEY,
787    id          UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() UNIQUE,
788    identifier  NVARCHAR(255) NOT NULL UNIQUE,
789    name        NVARCHAR(255) NOT NULL UNIQUE
790);
791GO
792
793CREATE OR ALTER VIEW v_tag AS
794SELECT pk_tag, id, identifier, name
795FROM tb_tag;
796GO
797";
798
799/// Generate per-entity SQL split into (table, view, functions) for S/M layouts
800fn generate_blog_entity_sql(database: Database, entity: &str) -> (String, String, String) {
801    if database != Database::Postgres {
802        // Non-Postgres databases get the full schema in the first entity file only,
803        // and empty strings for subsequent entities
804        if entity == "author" {
805            let single = generate_single_schema_sql(database);
806            return (single, String::new(), String::new());
807        }
808        return (
809            format!("-- See tb_author.sql for full {database} schema\n"),
810            String::new(),
811            String::new(),
812        );
813    }
814
815    match entity {
816        "author" => (
817            ENTITY_AUTHOR_TABLE.to_string(),
818            ENTITY_AUTHOR_VIEW.to_string(),
819            ENTITY_AUTHOR_FUNCTIONS.to_string(),
820        ),
821        "post" => (
822            ENTITY_POST_TABLE.to_string(),
823            ENTITY_POST_VIEW.to_string(),
824            ENTITY_POST_FUNCTIONS.to_string(),
825        ),
826        "comment" => (
827            ENTITY_COMMENT_TABLE.to_string(),
828            ENTITY_COMMENT_VIEW.to_string(),
829            ENTITY_COMMENT_FUNCTIONS.to_string(),
830        ),
831        "tag" => (
832            ENTITY_TAG_TABLE.to_string(),
833            ENTITY_TAG_VIEW.to_string(),
834            ENTITY_TAG_FUNCTIONS.to_string(),
835        ),
836        _ => (format!("-- Unknown entity: {entity}\n"), String::new(), String::new()),
837    }
838}
839
840// --- Per-entity Postgres SQL templates ---
841
842const ENTITY_AUTHOR_TABLE: &str = "\
843-- Table: author
844-- Trinity pattern: pk (internal), id (public UUID), identifier (URL slug)
845
846CREATE TABLE IF NOT EXISTS tb_author (
847    pk_author   SERIAL PRIMARY KEY,
848    id          UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
849    identifier  TEXT NOT NULL UNIQUE,
850    name        TEXT NOT NULL,
851    email       TEXT NOT NULL UNIQUE,
852    bio         TEXT,
853    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
854    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
855);
856
857CREATE INDEX IF NOT EXISTS idx_tb_author_email ON tb_author (email);
858";
859
860const ENTITY_AUTHOR_VIEW: &str = "\
861-- View: author (read-optimized)
862
863CREATE OR REPLACE VIEW v_author AS
864SELECT pk_author, id, identifier, name, email, bio, created_at, updated_at
865FROM tb_author;
866";
867
868const ENTITY_AUTHOR_FUNCTIONS: &str = "\
869-- CRUD functions for author
870
871CREATE OR REPLACE FUNCTION fn_author_create(
872    p_identifier TEXT,
873    p_name TEXT,
874    p_email TEXT,
875    p_bio TEXT DEFAULT NULL
876) RETURNS UUID
877LANGUAGE plpgsql AS $$
878DECLARE
879    v_id UUID;
880BEGIN
881    INSERT INTO tb_author (identifier, name, email, bio)
882    VALUES (p_identifier, p_name, p_email, p_bio)
883    RETURNING id INTO v_id;
884    RETURN v_id;
885END;
886$$;
887
888CREATE OR REPLACE FUNCTION fn_author_delete(p_id UUID)
889RETURNS BOOLEAN
890LANGUAGE plpgsql AS $$
891BEGIN
892    DELETE FROM tb_author WHERE id = p_id;
893    RETURN FOUND;
894END;
895$$;
896";
897
898const ENTITY_POST_TABLE: &str = "\
899-- Table: post
900-- Trinity pattern: pk (internal), id (public UUID), identifier (URL slug)
901
902CREATE TABLE IF NOT EXISTS tb_post (
903    pk_post     SERIAL PRIMARY KEY,
904    id          UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
905    identifier  TEXT NOT NULL UNIQUE,
906    title       TEXT NOT NULL,
907    body        TEXT NOT NULL,
908    published   BOOLEAN NOT NULL DEFAULT false,
909    author_id   UUID NOT NULL REFERENCES tb_author(id),
910    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
911    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
912);
913
914CREATE INDEX IF NOT EXISTS idx_tb_post_author ON tb_post (author_id);
915CREATE INDEX IF NOT EXISTS idx_tb_post_published ON tb_post (published) WHERE published = true;
916";
917
918const ENTITY_POST_VIEW: &str = "\
919-- View: post (read-optimized)
920
921CREATE OR REPLACE VIEW v_post AS
922SELECT pk_post, id, identifier, title, body, published, author_id, created_at, updated_at
923FROM tb_post;
924";
925
926const ENTITY_POST_FUNCTIONS: &str = "\
927-- CRUD functions for post
928
929CREATE OR REPLACE FUNCTION fn_post_create(
930    p_identifier TEXT,
931    p_title TEXT,
932    p_body TEXT,
933    p_author_id UUID
934) RETURNS UUID
935LANGUAGE plpgsql AS $$
936DECLARE
937    v_id UUID;
938BEGIN
939    INSERT INTO tb_post (identifier, title, body, author_id)
940    VALUES (p_identifier, p_title, p_body, p_author_id)
941    RETURNING id INTO v_id;
942    RETURN v_id;
943END;
944$$;
945
946CREATE OR REPLACE FUNCTION fn_post_publish(p_id UUID)
947RETURNS BOOLEAN
948LANGUAGE plpgsql AS $$
949BEGIN
950    UPDATE tb_post SET published = true, updated_at = now() WHERE id = p_id;
951    RETURN FOUND;
952END;
953$$;
954
955CREATE OR REPLACE FUNCTION fn_post_delete(p_id UUID)
956RETURNS BOOLEAN
957LANGUAGE plpgsql AS $$
958BEGIN
959    DELETE FROM tb_post WHERE id = p_id;
960    RETURN FOUND;
961END;
962$$;
963";
964
965const ENTITY_COMMENT_TABLE: &str = "\
966-- Table: comment
967
968CREATE TABLE IF NOT EXISTS tb_comment (
969    pk_comment  SERIAL PRIMARY KEY,
970    id          UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
971    body        TEXT NOT NULL,
972    author_name TEXT NOT NULL,
973    post_id     UUID NOT NULL REFERENCES tb_post(id) ON DELETE CASCADE,
974    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
975);
976
977CREATE INDEX IF NOT EXISTS idx_tb_comment_post ON tb_comment (post_id);
978";
979
980const ENTITY_COMMENT_VIEW: &str = "\
981-- View: comment (read-optimized)
982
983CREATE OR REPLACE VIEW v_comment AS
984SELECT pk_comment, id, body, author_name, post_id, created_at
985FROM tb_comment;
986";
987
988const ENTITY_COMMENT_FUNCTIONS: &str = "\
989-- CRUD functions for comment
990
991CREATE OR REPLACE FUNCTION fn_comment_create(
992    p_body TEXT,
993    p_author_name TEXT,
994    p_post_id UUID
995) RETURNS UUID
996LANGUAGE plpgsql AS $$
997DECLARE
998    v_id UUID;
999BEGIN
1000    INSERT INTO tb_comment (body, author_name, post_id)
1001    VALUES (p_body, p_author_name, p_post_id)
1002    RETURNING id INTO v_id;
1003    RETURN v_id;
1004END;
1005$$;
1006
1007CREATE OR REPLACE FUNCTION fn_comment_delete(p_id UUID)
1008RETURNS BOOLEAN
1009LANGUAGE plpgsql AS $$
1010BEGIN
1011    DELETE FROM tb_comment WHERE id = p_id;
1012    RETURN FOUND;
1013END;
1014$$;
1015";
1016
1017const ENTITY_TAG_TABLE: &str = "\
1018-- Table: tag
1019-- Trinity pattern: pk (internal), id (public UUID), identifier (URL slug)
1020
1021CREATE TABLE IF NOT EXISTS tb_tag (
1022    pk_tag      SERIAL PRIMARY KEY,
1023    id          UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
1024    identifier  TEXT NOT NULL UNIQUE,
1025    name        TEXT NOT NULL UNIQUE
1026);
1027";
1028
1029const ENTITY_TAG_VIEW: &str = "\
1030-- View: tag (read-optimized)
1031
1032CREATE OR REPLACE VIEW v_tag AS
1033SELECT pk_tag, id, identifier, name
1034FROM tb_tag;
1035";
1036
1037const ENTITY_TAG_FUNCTIONS: &str = "\
1038-- CRUD functions for tag
1039
1040CREATE OR REPLACE FUNCTION fn_tag_create(
1041    p_identifier TEXT,
1042    p_name TEXT
1043) RETURNS UUID
1044LANGUAGE plpgsql AS $$
1045DECLARE
1046    v_id UUID;
1047BEGIN
1048    INSERT INTO tb_tag (identifier, name)
1049    VALUES (p_identifier, p_name)
1050    RETURNING id INTO v_id;
1051    RETURN v_id;
1052END;
1053$$;
1054
1055CREATE OR REPLACE FUNCTION fn_tag_delete(p_id UUID)
1056RETURNS BOOLEAN
1057LANGUAGE plpgsql AS $$
1058BEGIN
1059    DELETE FROM tb_tag WHERE id = p_id;
1060    RETURN FOUND;
1061END;
1062$$;
1063";
1064
1065fn create_authoring_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1066    match config.language {
1067        Language::Python => create_python_skeleton(project_dir, config),
1068        Language::TypeScript => create_typescript_skeleton(project_dir, config),
1069        Language::Rust => create_rust_skeleton(project_dir, config),
1070        Language::Java => create_java_skeleton(project_dir, config),
1071        Language::Kotlin => create_kotlin_skeleton(project_dir, config),
1072        Language::Go => create_go_skeleton(project_dir, config),
1073        Language::CSharp => create_csharp_skeleton(project_dir, config),
1074        Language::Swift => create_swift_skeleton(project_dir, config),
1075        Language::Scala => create_scala_skeleton(project_dir, config),
1076    }
1077}
1078
1079fn create_python_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1080    let dir = project_dir.join("schema");
1081    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1082
1083    let content = format!(
1084        r#"""FraiseQL blog schema definition for {name}."""
1085
1086import fraiseql
1087
1088
1089@fraiseql.type(sql_source="v_author")
1090class Author:
1091    """Blog author with trinity pattern."""
1092
1093    pk: int
1094    id: ID
1095    identifier: str
1096    name: str
1097    email: str
1098    bio: str | None
1099    created_at: DateTime
1100    updated_at: DateTime
1101
1102
1103@fraiseql.type(sql_source="v_post")
1104class Post:
1105    """Blog post with trinity pattern."""
1106
1107    pk: int
1108    id: ID
1109    identifier: str
1110    title: str
1111    body: str
1112    published: bool
1113    author_id: ID
1114    created_at: DateTime
1115    updated_at: DateTime
1116
1117
1118@fraiseql.type(sql_source="v_comment")
1119class Comment:
1120    """Comment on a blog post."""
1121
1122    pk: int
1123    id: ID
1124    body: str
1125    author_name: str
1126    post_id: ID
1127    created_at: DateTime
1128
1129
1130@fraiseql.type(sql_source="v_tag")
1131class Tag:
1132    """Categorization tag for posts."""
1133
1134    pk: int
1135    id: ID
1136    identifier: str
1137    name: str
1138
1139
1140@fraiseql.query(return_type=Post, return_array=True, sql_source="v_post")
1141def posts() -> list[Post]:
1142    """List all published posts."""
1143    ...
1144
1145
1146@fraiseql.query(return_type=Post, sql_source="v_post")
1147def post(*, id: ID) -> Post:
1148    """Get post by ID."""
1149    ...
1150
1151
1152@fraiseql.query(return_type=Author, return_array=True, sql_source="v_author")
1153def authors() -> list[Author]:
1154    """List all authors."""
1155    ...
1156
1157
1158@fraiseql.query(return_type=Author, sql_source="v_author")
1159def author(*, id: ID) -> Author:
1160    """Get author by ID."""
1161    ...
1162
1163
1164@fraiseql.query(return_type=Tag, return_array=True, sql_source="v_tag")
1165def tags() -> list[Tag]:
1166    """List all tags."""
1167    ...
1168"#,
1169        name = config.project_name,
1170    );
1171
1172    fs::write(dir.join("schema.py"), content).context("Failed to create schema.py")?;
1173    info!("Created schema/schema.py");
1174    Ok(())
1175}
1176
1177fn create_typescript_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1178    let dir = project_dir.join("schema");
1179    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1180
1181    let content = format!(
1182        r#"/**
1183 * FraiseQL blog schema definition for {name}.
1184 */
1185
1186import {{ type_, query }} from "fraiseql";
1187
1188export const Author = type_("Author", {{
1189  sqlSource: "v_author",
1190  fields: {{
1191    pk: {{ type: "Int", nullable: false }},
1192    id: {{ type: "ID", nullable: false }},
1193    identifier: {{ type: "String", nullable: false }},
1194    name: {{ type: "String", nullable: false }},
1195    email: {{ type: "String", nullable: false }},
1196    bio: {{ type: "String", nullable: true }},
1197    created_at: {{ type: "DateTime", nullable: false }},
1198    updated_at: {{ type: "DateTime", nullable: false }},
1199  }},
1200}});
1201
1202export const Post = type_("Post", {{
1203  sqlSource: "v_post",
1204  fields: {{
1205    pk: {{ type: "Int", nullable: false }},
1206    id: {{ type: "ID", nullable: false }},
1207    identifier: {{ type: "String", nullable: false }},
1208    title: {{ type: "String", nullable: false }},
1209    body: {{ type: "String", nullable: false }},
1210    published: {{ type: "Boolean", nullable: false }},
1211    author_id: {{ type: "ID", nullable: false }},
1212    created_at: {{ type: "DateTime", nullable: false }},
1213    updated_at: {{ type: "DateTime", nullable: false }},
1214  }},
1215}});
1216
1217export const Comment = type_("Comment", {{
1218  sqlSource: "v_comment",
1219  fields: {{
1220    pk: {{ type: "Int", nullable: false }},
1221    id: {{ type: "ID", nullable: false }},
1222    body: {{ type: "String", nullable: false }},
1223    author_name: {{ type: "String", nullable: false }},
1224    post_id: {{ type: "ID", nullable: false }},
1225    created_at: {{ type: "DateTime", nullable: false }},
1226  }},
1227}});
1228
1229export const Tag = type_("Tag", {{
1230  sqlSource: "v_tag",
1231  fields: {{
1232    pk: {{ type: "Int", nullable: false }},
1233    id: {{ type: "ID", nullable: false }},
1234    identifier: {{ type: "String", nullable: false }},
1235    name: {{ type: "String", nullable: false }},
1236  }},
1237}});
1238
1239export const posts = query("posts", {{
1240  returnType: "Post",
1241  returnArray: true,
1242  sqlSource: "v_post",
1243}});
1244
1245export const post = query("post", {{
1246  returnType: "Post",
1247  returnArray: false,
1248  sqlSource: "v_post",
1249  args: [{{ name: "id", type: "ID", required: true }}],
1250}});
1251
1252export const authors = query("authors", {{
1253  returnType: "Author",
1254  returnArray: true,
1255  sqlSource: "v_author",
1256}});
1257
1258export const author = query("author", {{
1259  returnType: "Author",
1260  returnArray: false,
1261  sqlSource: "v_author",
1262  args: [{{ name: "id", type: "ID", required: true }}],
1263}});
1264
1265export const tagsQuery = query("tags", {{
1266  returnType: "Tag",
1267  returnArray: true,
1268  sqlSource: "v_tag",
1269}});
1270"#,
1271        name = config.project_name,
1272    );
1273
1274    fs::write(dir.join("schema.ts"), content).context("Failed to create schema.ts")?;
1275    info!("Created schema/schema.ts");
1276    Ok(())
1277}
1278
1279fn create_rust_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1280    let dir = project_dir.join("schema");
1281    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1282
1283    let content = format!(
1284        r#"//! FraiseQL blog schema definition for {name}.
1285
1286use fraiseql::{{type_, query}};
1287
1288/// Blog author with trinity pattern.
1289#[type_(sql_source = "v_author")]
1290pub struct Author {{
1291    pub pk: i32,
1292    pub id: ID,
1293    pub identifier: String,
1294    pub name: String,
1295    pub email: String,
1296    pub bio: Option<String>,
1297    pub created_at: DateTime,
1298    pub updated_at: DateTime,
1299}}
1300
1301/// Blog post with trinity pattern.
1302#[type_(sql_source = "v_post")]
1303pub struct Post {{
1304    pub pk: i32,
1305    pub id: ID,
1306    pub identifier: String,
1307    pub title: String,
1308    pub body: String,
1309    pub published: bool,
1310    pub author_id: ID,
1311    pub created_at: DateTime,
1312    pub updated_at: DateTime,
1313}}
1314
1315/// Comment on a blog post.
1316#[type_(sql_source = "v_comment")]
1317pub struct Comment {{
1318    pub pk: i32,
1319    pub id: ID,
1320    pub body: String,
1321    pub author_name: String,
1322    pub post_id: ID,
1323    pub created_at: DateTime,
1324}}
1325
1326/// Categorization tag for posts.
1327#[type_(sql_source = "v_tag")]
1328pub struct Tag {{
1329    pub pk: i32,
1330    pub id: ID,
1331    pub identifier: String,
1332    pub name: String,
1333}}
1334
1335#[query(return_type = "Post", return_array = true, sql_source = "v_post")]
1336pub fn posts() -> Vec<Post> {{
1337    unimplemented!("Schema definition only")
1338}}
1339
1340#[query(return_type = "Post", sql_source = "v_post")]
1341pub fn post(id: ID) -> Post {{
1342    unimplemented!("Schema definition only")
1343}}
1344
1345#[query(return_type = "Author", return_array = true, sql_source = "v_author")]
1346pub fn authors() -> Vec<Author> {{
1347    unimplemented!("Schema definition only")
1348}}
1349
1350#[query(return_type = "Author", sql_source = "v_author")]
1351pub fn author(id: ID) -> Author {{
1352    unimplemented!("Schema definition only")
1353}}
1354
1355#[query(return_type = "Tag", return_array = true, sql_source = "v_tag")]
1356pub fn tags() -> Vec<Tag> {{
1357    unimplemented!("Schema definition only")
1358}}
1359"#,
1360        name = config.project_name,
1361    );
1362
1363    fs::write(dir.join("schema.rs"), content).context("Failed to create schema.rs")?;
1364    info!("Created schema/schema.rs");
1365    Ok(())
1366}
1367
1368fn create_java_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1369    let dir = project_dir.join("schema");
1370    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1371
1372    let content = format!(
1373        r#"// FraiseQL blog schema definition for {name}.
1374
1375package schema;
1376
1377import fraiseql.FraiseQL;
1378import fraiseql.annotations.*;
1379
1380/// Blog author with trinity pattern.
1381@Type(sqlSource = "v_author")
1382public record Author(
1383    int pk,
1384    ID id,
1385    String identifier,
1386    String name,
1387    String email,
1388    @Nullable String bio,
1389    DateTime createdAt,
1390    DateTime updatedAt
1391) {{}}
1392
1393/// Blog post with trinity pattern.
1394@Type(sqlSource = "v_post")
1395public record Post(
1396    int pk,
1397    ID id,
1398    String identifier,
1399    String title,
1400    String body,
1401    boolean published,
1402    ID authorId,
1403    DateTime createdAt,
1404    DateTime updatedAt
1405) {{}}
1406
1407/// Comment on a blog post.
1408@Type(sqlSource = "v_comment")
1409public record Comment(
1410    int pk,
1411    ID id,
1412    String body,
1413    String authorName,
1414    ID postId,
1415    DateTime createdAt
1416) {{}}
1417
1418/// Categorization tag for posts.
1419@Type(sqlSource = "v_tag")
1420public record Tag(
1421    int pk,
1422    ID id,
1423    String identifier,
1424    String name
1425) {{}}
1426
1427@Query(returnType = Post.class, returnArray = true, sqlSource = "v_post")
1428public interface Posts {{}}
1429
1430@Query(returnType = Post.class, sqlSource = "v_post", args = @Arg(name = "id", type = "ID", required = true))
1431public interface PostById {{}}
1432
1433@Query(returnType = Author.class, returnArray = true, sqlSource = "v_author")
1434public interface Authors {{}}
1435
1436@Query(returnType = Author.class, sqlSource = "v_author", args = @Arg(name = "id", type = "ID", required = true))
1437public interface AuthorById {{}}
1438
1439@Query(returnType = Tag.class, returnArray = true, sqlSource = "v_tag")
1440public interface Tags {{}}
1441"#,
1442        name = config.project_name,
1443    );
1444
1445    fs::write(dir.join("schema.java"), content).context("Failed to create schema.java")?;
1446    info!("Created schema/schema.java");
1447    Ok(())
1448}
1449
1450fn create_kotlin_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1451    let dir = project_dir.join("schema");
1452    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1453
1454    let content = format!(
1455        r#"// FraiseQL blog schema definition for {name}.
1456
1457package schema
1458
1459import fraiseql.*
1460
1461/// Blog author with trinity pattern.
1462@Type(sqlSource = "v_author")
1463data class Author(
1464    val pk: Int,
1465    val id: ID,
1466    val identifier: String,
1467    val name: String,
1468    val email: String,
1469    val bio: String?,
1470    val createdAt: DateTime,
1471    val updatedAt: DateTime,
1472)
1473
1474/// Blog post with trinity pattern.
1475@Type(sqlSource = "v_post")
1476data class Post(
1477    val pk: Int,
1478    val id: ID,
1479    val identifier: String,
1480    val title: String,
1481    val body: String,
1482    val published: Boolean,
1483    val authorId: ID,
1484    val createdAt: DateTime,
1485    val updatedAt: DateTime,
1486)
1487
1488/// Comment on a blog post.
1489@Type(sqlSource = "v_comment")
1490data class Comment(
1491    val pk: Int,
1492    val id: ID,
1493    val body: String,
1494    val authorName: String,
1495    val postId: ID,
1496    val createdAt: DateTime,
1497)
1498
1499/// Categorization tag for posts.
1500@Type(sqlSource = "v_tag")
1501data class Tag(
1502    val pk: Int,
1503    val id: ID,
1504    val identifier: String,
1505    val name: String,
1506)
1507
1508@Query(returnType = Post::class, returnArray = true, sqlSource = "v_post")
1509fun posts(): List<Post> = TODO("Schema definition only")
1510
1511@Query(returnType = Post::class, sqlSource = "v_post")
1512fun post(id: ID): Post = TODO("Schema definition only")
1513
1514@Query(returnType = Author::class, returnArray = true, sqlSource = "v_author")
1515fun authors(): List<Author> = TODO("Schema definition only")
1516
1517@Query(returnType = Author::class, sqlSource = "v_author")
1518fun author(id: ID): Author = TODO("Schema definition only")
1519
1520@Query(returnType = Tag::class, returnArray = true, sqlSource = "v_tag")
1521fun tags(): List<Tag> = TODO("Schema definition only")
1522"#,
1523        name = config.project_name,
1524    );
1525
1526    fs::write(dir.join("schema.kt"), content).context("Failed to create schema.kt")?;
1527    info!("Created schema/schema.kt");
1528    Ok(())
1529}
1530
1531fn create_go_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1532    let dir = project_dir.join("schema");
1533    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1534
1535    let content = format!(
1536        r#"// FraiseQL blog schema definition for {name}.
1537
1538package schema
1539
1540import "fraiseql"
1541
1542// Author - Blog author with trinity pattern.
1543// @Type(sqlSource = "v_author")
1544type Author struct {{
1545	PK         int      `fraiseql:"pk"`
1546	ID         ID       `fraiseql:"id"`
1547	Identifier string   `fraiseql:"identifier"`
1548	Name       string   `fraiseql:"name"`
1549	Email      string   `fraiseql:"email"`
1550	Bio        *string  `fraiseql:"bio"`
1551	CreatedAt  DateTime `fraiseql:"created_at"`
1552	UpdatedAt  DateTime `fraiseql:"updated_at"`
1553}}
1554
1555// Post - Blog post with trinity pattern.
1556// @Type(sqlSource = "v_post")
1557type Post struct {{
1558	PK         int      `fraiseql:"pk"`
1559	ID         ID       `fraiseql:"id"`
1560	Identifier string   `fraiseql:"identifier"`
1561	Title      string   `fraiseql:"title"`
1562	Body       string   `fraiseql:"body"`
1563	Published  bool     `fraiseql:"published"`
1564	AuthorID   ID       `fraiseql:"author_id"`
1565	CreatedAt  DateTime `fraiseql:"created_at"`
1566	UpdatedAt  DateTime `fraiseql:"updated_at"`
1567}}
1568
1569// Comment - Comment on a blog post.
1570// @Type(sqlSource = "v_comment")
1571type Comment struct {{
1572	PK         int      `fraiseql:"pk"`
1573	ID         ID       `fraiseql:"id"`
1574	Body       string   `fraiseql:"body"`
1575	AuthorName string   `fraiseql:"author_name"`
1576	PostID     ID       `fraiseql:"post_id"`
1577	CreatedAt  DateTime `fraiseql:"created_at"`
1578}}
1579
1580// Tag - Categorization tag for posts.
1581// @Type(sqlSource = "v_tag")
1582type Tag struct {{
1583	PK         int    `fraiseql:"pk"`
1584	ID         ID     `fraiseql:"id"`
1585	Identifier string `fraiseql:"identifier"`
1586	Name       string `fraiseql:"name"`
1587}}
1588
1589// Queries are registered via fraiseql.RegisterQuery().
1590func init() {{
1591	fraiseql.RegisterQuery("posts", fraiseql.QueryDef{{ReturnType: "Post", ReturnArray: true, SQLSource: "v_post"}})
1592	fraiseql.RegisterQuery("post", fraiseql.QueryDef{{ReturnType: "Post", SQLSource: "v_post", Args: []fraiseql.Arg{{{{Name: "id", Type: "ID", Required: true}}}}}})
1593	fraiseql.RegisterQuery("authors", fraiseql.QueryDef{{ReturnType: "Author", ReturnArray: true, SQLSource: "v_author"}})
1594	fraiseql.RegisterQuery("author", fraiseql.QueryDef{{ReturnType: "Author", SQLSource: "v_author", Args: []fraiseql.Arg{{{{Name: "id", Type: "ID", Required: true}}}}}})
1595	fraiseql.RegisterQuery("tags", fraiseql.QueryDef{{ReturnType: "Tag", ReturnArray: true, SQLSource: "v_tag"}})
1596}}
1597"#,
1598        name = config.project_name,
1599    );
1600
1601    fs::write(dir.join("schema.go"), content).context("Failed to create schema.go")?;
1602    info!("Created schema/schema.go");
1603    Ok(())
1604}
1605
1606fn create_csharp_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1607    let dir = project_dir.join("schema");
1608    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1609
1610    let content = format!(
1611        r#"// FraiseQL blog schema definition for {name}.
1612
1613using FraiseQL;
1614
1615namespace Schema;
1616
1617/// Blog author with trinity pattern.
1618[Type(SqlSource = "v_author")]
1619public record Author(
1620    int Pk,
1621    ID Id,
1622    string Identifier,
1623    string Name,
1624    string Email,
1625    string? Bio,
1626    DateTime CreatedAt,
1627    DateTime UpdatedAt
1628);
1629
1630/// Blog post with trinity pattern.
1631[Type(SqlSource = "v_post")]
1632public record Post(
1633    int Pk,
1634    ID Id,
1635    string Identifier,
1636    string Title,
1637    string Body,
1638    bool Published,
1639    ID AuthorId,
1640    DateTime CreatedAt,
1641    DateTime UpdatedAt
1642);
1643
1644/// Comment on a blog post.
1645[Type(SqlSource = "v_comment")]
1646public record Comment(
1647    int Pk,
1648    ID Id,
1649    string Body,
1650    string AuthorName,
1651    ID PostId,
1652    DateTime CreatedAt
1653);
1654
1655/// Categorization tag for posts.
1656[Type(SqlSource = "v_tag")]
1657public record Tag(
1658    int Pk,
1659    ID Id,
1660    string Identifier,
1661    string Name
1662);
1663
1664[Query(ReturnType = typeof(Post), ReturnArray = true, SqlSource = "v_post")]
1665public static partial class Posts;
1666
1667[Query(ReturnType = typeof(Post), SqlSource = "v_post", Arg(Name = "id", Type = "ID", Required = true))]
1668public static partial class PostById;
1669
1670[Query(ReturnType = typeof(Author), ReturnArray = true, SqlSource = "v_author")]
1671public static partial class Authors;
1672
1673[Query(ReturnType = typeof(Author), SqlSource = "v_author", Arg(Name = "id", Type = "ID", Required = true))]
1674public static partial class AuthorById;
1675
1676[Query(ReturnType = typeof(Tag), ReturnArray = true, SqlSource = "v_tag")]
1677public static partial class Tags;
1678"#,
1679        name = config.project_name,
1680    );
1681
1682    fs::write(dir.join("schema.cs"), content).context("Failed to create schema.cs")?;
1683    info!("Created schema/schema.cs");
1684    Ok(())
1685}
1686
1687fn create_swift_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1688    let dir = project_dir.join("schema");
1689    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1690
1691    let content = format!(
1692        r#"// FraiseQL blog schema definition for {name}.
1693
1694import FraiseQL
1695
1696/// Blog author with trinity pattern.
1697@Type(sqlSource: "v_author")
1698struct Author {{
1699    let pk: Int
1700    let id: ID
1701    let identifier: String
1702    let name: String
1703    let email: String
1704    let bio: String?
1705    let createdAt: DateTime
1706    let updatedAt: DateTime
1707}}
1708
1709/// Blog post with trinity pattern.
1710@Type(sqlSource: "v_post")
1711struct Post {{
1712    let pk: Int
1713    let id: ID
1714    let identifier: String
1715    let title: String
1716    let body: String
1717    let published: Bool
1718    let authorId: ID
1719    let createdAt: DateTime
1720    let updatedAt: DateTime
1721}}
1722
1723/// Comment on a blog post.
1724@Type(sqlSource: "v_comment")
1725struct Comment {{
1726    let pk: Int
1727    let id: ID
1728    let body: String
1729    let authorName: String
1730    let postId: ID
1731    let createdAt: DateTime
1732}}
1733
1734/// Categorization tag for posts.
1735@Type(sqlSource: "v_tag")
1736struct Tag {{
1737    let pk: Int
1738    let id: ID
1739    let identifier: String
1740    let name: String
1741}}
1742
1743@Query(returnType: Post.self, returnArray: true, sqlSource: "v_post")
1744func posts() -> [Post] {{ fatalError("Schema definition only") }}
1745
1746@Query(returnType: Post.self, sqlSource: "v_post")
1747func post(id: ID) -> Post {{ fatalError("Schema definition only") }}
1748
1749@Query(returnType: Author.self, returnArray: true, sqlSource: "v_author")
1750func authors() -> [Author] {{ fatalError("Schema definition only") }}
1751
1752@Query(returnType: Author.self, sqlSource: "v_author")
1753func author(id: ID) -> Author {{ fatalError("Schema definition only") }}
1754
1755@Query(returnType: Tag.self, returnArray: true, sqlSource: "v_tag")
1756func tags() -> [Tag] {{ fatalError("Schema definition only") }}
1757"#,
1758        name = config.project_name,
1759    );
1760
1761    fs::write(dir.join("schema.swift"), content).context("Failed to create schema.swift")?;
1762    info!("Created schema/schema.swift");
1763    Ok(())
1764}
1765
1766fn create_scala_skeleton(project_dir: &Path, config: &InitConfig) -> Result<()> {
1767    let dir = project_dir.join("schema");
1768    fs::create_dir_all(&dir).context("Failed to create schema/ directory")?;
1769
1770    let content = format!(
1771        r#"// FraiseQL blog schema definition for {name}.
1772
1773package schema
1774
1775import fraiseql._
1776
1777/// Blog author with trinity pattern.
1778@Type(sqlSource = "v_author")
1779case class Author(
1780  pk: Int,
1781  id: ID,
1782  identifier: String,
1783  name: String,
1784  email: String,
1785  bio: Option[String],
1786  createdAt: DateTime,
1787  updatedAt: DateTime
1788)
1789
1790/// Blog post with trinity pattern.
1791@Type(sqlSource = "v_post")
1792case class Post(
1793  pk: Int,
1794  id: ID,
1795  identifier: String,
1796  title: String,
1797  body: String,
1798  published: Boolean,
1799  authorId: ID,
1800  createdAt: DateTime,
1801  updatedAt: DateTime
1802)
1803
1804/// Comment on a blog post.
1805@Type(sqlSource = "v_comment")
1806case class Comment(
1807  pk: Int,
1808  id: ID,
1809  body: String,
1810  authorName: String,
1811  postId: ID,
1812  createdAt: DateTime
1813)
1814
1815/// Categorization tag for posts.
1816@Type(sqlSource = "v_tag")
1817case class Tag(
1818  pk: Int,
1819  id: ID,
1820  identifier: String,
1821  name: String
1822)
1823
1824@Query(returnType = classOf[Post], returnArray = true, sqlSource = "v_post")
1825def posts(): List[Post] = ???
1826
1827@Query(returnType = classOf[Post], sqlSource = "v_post")
1828def post(id: ID): Post = ???
1829
1830@Query(returnType = classOf[Author], returnArray = true, sqlSource = "v_author")
1831def authors(): List[Author] = ???
1832
1833@Query(returnType = classOf[Author], sqlSource = "v_author")
1834def author(id: ID): Author = ???
1835
1836@Query(returnType = classOf[Tag], returnArray = true, sqlSource = "v_tag")
1837def tags(): List[Tag] = ???
1838"#,
1839        name = config.project_name,
1840    );
1841
1842    fs::write(dir.join("schema.scala"), content).context("Failed to create schema.scala")?;
1843    info!("Created schema/schema.scala");
1844    Ok(())
1845}
1846
1847fn init_git(project_dir: &Path) -> Result<()> {
1848    let status = Command::new("git")
1849        .args(["init"])
1850        .current_dir(project_dir)
1851        .stdout(std::process::Stdio::null())
1852        .stderr(std::process::Stdio::null())
1853        .status();
1854
1855    match status {
1856        Ok(s) if s.success() => {
1857            info!("Initialized git repository");
1858            Ok(())
1859        },
1860        Ok(_) => {
1861            // git init failed but non-fatal
1862            eprintln!("Warning: git init failed. You can initialize git manually.");
1863            Ok(())
1864        },
1865        Err(_) => {
1866            eprintln!("Warning: git not found. Skipping repository initialization.");
1867            Ok(())
1868        },
1869    }
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874    use super::*;
1875
1876    #[test]
1877    fn test_language_from_str() {
1878        assert_eq!(Language::from_str("python").unwrap(), Language::Python);
1879        assert_eq!(Language::from_str("py").unwrap(), Language::Python);
1880        assert_eq!(Language::from_str("typescript").unwrap(), Language::TypeScript);
1881        assert_eq!(Language::from_str("ts").unwrap(), Language::TypeScript);
1882        assert_eq!(Language::from_str("rust").unwrap(), Language::Rust);
1883        assert_eq!(Language::from_str("rs").unwrap(), Language::Rust);
1884        assert_eq!(Language::from_str("java").unwrap(), Language::Java);
1885        assert_eq!(Language::from_str("jav").unwrap(), Language::Java);
1886        assert_eq!(Language::from_str("kotlin").unwrap(), Language::Kotlin);
1887        assert_eq!(Language::from_str("kt").unwrap(), Language::Kotlin);
1888        assert_eq!(Language::from_str("go").unwrap(), Language::Go);
1889        assert_eq!(Language::from_str("golang").unwrap(), Language::Go);
1890        assert_eq!(Language::from_str("csharp").unwrap(), Language::CSharp);
1891        assert_eq!(Language::from_str("c#").unwrap(), Language::CSharp);
1892        assert_eq!(Language::from_str("cs").unwrap(), Language::CSharp);
1893        assert_eq!(Language::from_str("swift").unwrap(), Language::Swift);
1894        assert_eq!(Language::from_str("scala").unwrap(), Language::Scala);
1895        assert_eq!(Language::from_str("sc").unwrap(), Language::Scala);
1896        assert!(Language::from_str("haskell").is_err());
1897    }
1898
1899    #[test]
1900    fn test_language_from_extension() {
1901        assert_eq!(Language::from_extension("py"), Some(Language::Python));
1902        assert_eq!(Language::from_extension("ts"), Some(Language::TypeScript));
1903        assert_eq!(Language::from_extension("tsx"), Some(Language::TypeScript));
1904        assert_eq!(Language::from_extension("rs"), Some(Language::Rust));
1905        assert_eq!(Language::from_extension("java"), Some(Language::Java));
1906        assert_eq!(Language::from_extension("kt"), Some(Language::Kotlin));
1907        assert_eq!(Language::from_extension("kts"), Some(Language::Kotlin));
1908        assert_eq!(Language::from_extension("go"), Some(Language::Go));
1909        assert_eq!(Language::from_extension("cs"), Some(Language::CSharp));
1910        assert_eq!(Language::from_extension("swift"), Some(Language::Swift));
1911        assert_eq!(Language::from_extension("scala"), Some(Language::Scala));
1912        assert_eq!(Language::from_extension("sc"), Some(Language::Scala));
1913        assert_eq!(Language::from_extension("rb"), None);
1914        assert_eq!(Language::from_extension(""), None);
1915    }
1916
1917    #[test]
1918    fn test_database_from_str() {
1919        assert_eq!(Database::from_str("postgres").unwrap(), Database::Postgres);
1920        assert_eq!(Database::from_str("postgresql").unwrap(), Database::Postgres);
1921        assert_eq!(Database::from_str("pg").unwrap(), Database::Postgres);
1922        assert_eq!(Database::from_str("mysql").unwrap(), Database::Mysql);
1923        assert_eq!(Database::from_str("sqlite").unwrap(), Database::Sqlite);
1924        assert_eq!(Database::from_str("sqlserver").unwrap(), Database::SqlServer);
1925        assert_eq!(Database::from_str("mssql").unwrap(), Database::SqlServer);
1926        assert!(Database::from_str("oracle").is_err());
1927    }
1928
1929    #[test]
1930    fn test_size_from_str() {
1931        assert_eq!(ProjectSize::from_str("xs").unwrap(), ProjectSize::Xs);
1932        assert_eq!(ProjectSize::from_str("s").unwrap(), ProjectSize::S);
1933        assert_eq!(ProjectSize::from_str("m").unwrap(), ProjectSize::M);
1934        assert!(ProjectSize::from_str("l").is_err());
1935    }
1936
1937    #[test]
1938    fn test_database_default_url() {
1939        assert_eq!(Database::Postgres.default_url("myapp"), "postgresql://localhost/myapp");
1940        assert_eq!(Database::Sqlite.default_url("myapp"), "myapp.db");
1941    }
1942
1943    #[test]
1944    fn test_init_creates_project() {
1945        let tmp = tempfile::tempdir().unwrap();
1946        let project_dir = tmp.path().join("test_project");
1947
1948        let config = InitConfig {
1949            project_name: project_dir.to_string_lossy().to_string(),
1950            language:     Language::Python,
1951            database:     Database::Postgres,
1952            size:         ProjectSize::S,
1953            no_git:       true,
1954        };
1955
1956        run(&config).unwrap();
1957
1958        // Verify files exist
1959        assert!(project_dir.join(".gitignore").exists());
1960        assert!(project_dir.join("fraiseql.toml").exists());
1961        assert!(project_dir.join("schema.json").exists());
1962        assert!(project_dir.join("db/0_schema/01_write/011_tb_author.sql").exists());
1963        assert!(project_dir.join("db/0_schema/01_write/012_tb_post.sql").exists());
1964        assert!(project_dir.join("db/0_schema/01_write/013_tb_comment.sql").exists());
1965        assert!(project_dir.join("db/0_schema/01_write/014_tb_tag.sql").exists());
1966        assert!(project_dir.join("db/0_schema/02_read/021_v_author.sql").exists());
1967        assert!(project_dir.join("db/0_schema/03_functions/031_fn_author_crud.sql").exists());
1968        // Selected language skeleton only
1969        assert!(project_dir.join("schema/schema.py").exists());
1970        assert!(!project_dir.join("schema/schema.ts").exists());
1971        assert!(!project_dir.join("schema/schema.rs").exists());
1972    }
1973
1974    #[test]
1975    fn test_init_xs_layout() {
1976        let tmp = tempfile::tempdir().unwrap();
1977        let project_dir = tmp.path().join("test_xs");
1978
1979        let config = InitConfig {
1980            project_name: project_dir.to_string_lossy().to_string(),
1981            language:     Language::TypeScript,
1982            database:     Database::Postgres,
1983            size:         ProjectSize::Xs,
1984            no_git:       true,
1985        };
1986
1987        run(&config).unwrap();
1988
1989        assert!(project_dir.join("db/0_schema/schema.sql").exists());
1990        assert!(project_dir.join("schema/schema.ts").exists());
1991
1992        // Should NOT have the numbered directories
1993        assert!(!project_dir.join("db/0_schema/01_write").exists());
1994    }
1995
1996    #[test]
1997    fn test_init_m_layout() {
1998        let tmp = tempfile::tempdir().unwrap();
1999        let project_dir = tmp.path().join("test_m");
2000
2001        let config = InitConfig {
2002            project_name: project_dir.to_string_lossy().to_string(),
2003            language:     Language::Rust,
2004            database:     Database::Postgres,
2005            size:         ProjectSize::M,
2006            no_git:       true,
2007        };
2008
2009        run(&config).unwrap();
2010
2011        assert!(project_dir.join("db/0_schema/01_write/author/tb_author.sql").exists());
2012        assert!(project_dir.join("db/0_schema/01_write/post/tb_post.sql").exists());
2013        assert!(project_dir.join("db/0_schema/02_read/author/v_author.sql").exists());
2014        assert!(project_dir.join("db/0_schema/03_functions/author/fn_author_crud.sql").exists());
2015        assert!(project_dir.join("schema/schema.rs").exists());
2016    }
2017
2018    #[test]
2019    fn test_init_refuses_existing_dir() {
2020        let tmp = tempfile::tempdir().unwrap();
2021        let project_dir = tmp.path().join("existing");
2022
2023        fs::create_dir(&project_dir).unwrap();
2024
2025        let config = InitConfig {
2026            project_name: project_dir.to_string_lossy().to_string(),
2027            language:     Language::Python,
2028            database:     Database::Postgres,
2029            size:         ProjectSize::S,
2030            no_git:       true,
2031        };
2032
2033        let result = run(&config);
2034        assert!(result.is_err());
2035        assert!(result.unwrap_err().to_string().contains("already exists"));
2036    }
2037
2038    #[test]
2039    fn test_toml_config_is_valid() {
2040        let tmp = tempfile::tempdir().unwrap();
2041        let project_dir = tmp.path().join("toml_test");
2042
2043        let config = InitConfig {
2044            project_name: project_dir.to_string_lossy().to_string(),
2045            language:     Language::Python,
2046            database:     Database::Postgres,
2047            size:         ProjectSize::S,
2048            no_git:       true,
2049        };
2050
2051        run(&config).unwrap();
2052
2053        // Verify the TOML can be parsed
2054        let toml_content = fs::read_to_string(project_dir.join("fraiseql.toml")).unwrap();
2055        let parsed: toml::Value = toml::from_str(&toml_content).unwrap();
2056        // project name in TOML is the full path since we pass absolute paths
2057        assert!(parsed["project"]["name"].as_str().is_some());
2058    }
2059
2060    #[test]
2061    fn test_schema_json_is_valid() {
2062        let tmp = tempfile::tempdir().unwrap();
2063        let project_dir = tmp.path().join("json_test");
2064
2065        let config = InitConfig {
2066            project_name: project_dir.to_string_lossy().to_string(),
2067            language:     Language::Python,
2068            database:     Database::Postgres,
2069            size:         ProjectSize::Xs,
2070            no_git:       true,
2071        };
2072
2073        run(&config).unwrap();
2074
2075        let json_content = fs::read_to_string(project_dir.join("schema.json")).unwrap();
2076        let parsed: serde_json::Value = serde_json::from_str(&json_content).unwrap();
2077
2078        // IntermediateSchema format: arrays, not maps
2079        assert!(parsed["types"].is_array(), "types should be an array");
2080        assert!(parsed["queries"].is_array(), "queries should be an array");
2081        assert_eq!(parsed["types"][0]["name"], "Author");
2082        assert_eq!(parsed["types"][1]["name"], "Post");
2083        assert_eq!(parsed["types"][2]["name"], "Comment");
2084        assert_eq!(parsed["types"][3]["name"], "Tag");
2085        assert_eq!(parsed["queries"][0]["name"], "posts");
2086        assert_eq!(parsed["version"], "2.0.0");
2087    }
2088}