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