Skip to main content

fraiseql_cli/commands/init/
mod.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
6mod skeletons;
7mod sql_templates;
8
9use std::{
10    fmt, fs,
11    path::{Path, PathBuf},
12    str::FromStr,
13};
14
15use anyhow::{Context, Result};
16use tracing::info;
17
18/// Supported authoring languages
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Language {
22    /// Python authoring (default)
23    Python,
24    /// TypeScript authoring
25    TypeScript,
26    /// Rust authoring
27    Rust,
28    /// Java authoring
29    Java,
30    /// Kotlin authoring
31    Kotlin,
32    /// Go authoring
33    Go,
34    /// C# authoring
35    CSharp,
36    /// Swift authoring
37    Swift,
38    /// Scala authoring
39    Scala,
40    /// PHP authoring
41    Php,
42}
43
44impl fmt::Display for Language {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Python => write!(f, "python"),
48            Self::TypeScript => write!(f, "typescript"),
49            Self::Rust => write!(f, "rust"),
50            Self::Java => write!(f, "java"),
51            Self::Kotlin => write!(f, "kotlin"),
52            Self::Go => write!(f, "go"),
53            Self::CSharp => write!(f, "csharp"),
54            Self::Swift => write!(f, "swift"),
55            Self::Scala => write!(f, "scala"),
56            Self::Php => write!(f, "php"),
57        }
58    }
59}
60
61impl Language {
62    /// Map file extension to language (for `fraiseql extract` auto-detection).
63    pub fn from_extension(ext: &str) -> Option<Self> {
64        match ext {
65            "py" => Some(Self::Python),
66            "ts" | "tsx" => Some(Self::TypeScript),
67            "rs" => Some(Self::Rust),
68            "java" => Some(Self::Java),
69            "kt" | "kts" => Some(Self::Kotlin),
70            "go" => Some(Self::Go),
71            "cs" => Some(Self::CSharp),
72            "swift" => Some(Self::Swift),
73            "scala" | "sc" => Some(Self::Scala),
74            "php" => Some(Self::Php),
75            _ => None,
76        }
77    }
78}
79
80impl FromStr for Language {
81    type Err = String;
82
83    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
84        match s.to_lowercase().as_str() {
85            "python" | "py" => Ok(Self::Python),
86            "typescript" | "ts" => Ok(Self::TypeScript),
87            "rust" | "rs" => Ok(Self::Rust),
88            "java" | "jav" => Ok(Self::Java),
89            "kotlin" | "kt" => Ok(Self::Kotlin),
90            "go" | "golang" => Ok(Self::Go),
91            "csharp" | "c#" | "cs" => Ok(Self::CSharp),
92            "swift" => Ok(Self::Swift),
93            "scala" | "sc" => Ok(Self::Scala),
94            "php" => Ok(Self::Php),
95            other => Err(format!(
96                "Unknown language: {other}. Choose: python, typescript, rust, java, kotlin, go, csharp, swift, scala, php"
97            )),
98        }
99    }
100}
101
102/// The scaffold's blog entities, in dependency order (tables reference earlier
103/// entities). Shared by the SQL templates, the DDL file layout, and
104/// `schema.json` so the three cannot drift apart.
105pub(super) const ENTITIES: [&str; 4] = ["author", "post", "comment", "tag"];
106
107/// Supported databases: PostgreSQL only.
108///
109/// The MySQL, SQLite and SQL Server backends were removed in v2.15.0 (#374,
110/// gate G2); `init` refuses them loudly rather than scaffolding a project the
111/// runtime cannot boot.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum Database {
115    /// PostgreSQL — the only supported engine.
116    Postgres,
117}
118
119impl fmt::Display for Database {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::Postgres => write!(f, "postgres"),
123        }
124    }
125}
126
127impl FromStr for Database {
128    type Err = String;
129
130    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
131        match s.to_lowercase().as_str() {
132            "postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
133            removed @ ("mysql" | "sqlite" | "sqlserver" | "mssql") => Err(format!(
134                "FraiseQL is PostgreSQL-only: {removed} support was removed in v2.15.0 \
135                 (see docs/database-compatibility.md). Use --database postgres."
136            )),
137            other => Err(format!("Unknown database: {other}. Choose: postgres")),
138        }
139    }
140}
141
142/// Database target string for fraiseql.toml
143impl Database {
144    const fn toml_target(self) -> &'static str {
145        match self {
146            Self::Postgres => "postgresql",
147        }
148    }
149
150    fn default_url(self, project_name: &str) -> String {
151        match self {
152            Self::Postgres => format!("postgresql://localhost/{project_name}"),
153        }
154    }
155}
156
157/// Project size determines directory granularity
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum ProjectSize {
161    /// Single `schema.sql` file
162    Xs,
163    /// Flat numbered directories (01_write, 02_read, 03_functions)
164    S,
165    /// Per-entity subdirectories under each numbered directory
166    M,
167}
168
169impl FromStr for ProjectSize {
170    type Err = String;
171
172    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
173        match s.to_lowercase().as_str() {
174            "xs" => Ok(Self::Xs),
175            "s" => Ok(Self::S),
176            "m" => Ok(Self::M),
177            other => Err(format!("Unknown size: {other}. Choose: xs, s, m")),
178        }
179    }
180}
181
182/// Configuration for the init command
183pub struct InitConfig {
184    /// Name of the project (used as directory name)
185    pub project_name: String,
186    /// Authoring language for the schema skeleton
187    pub language:     Language,
188    /// Target database engine
189    pub database:     Database,
190    /// Project size (directory granularity)
191    pub size:         ProjectSize,
192    /// Skip git initialization
193    pub no_git:       bool,
194}
195
196/// Run the init command
197///
198/// # Errors
199///
200/// Returns an error if the target directory already exists, if any file or
201/// directory creation fails, or if the language skeleton cannot be written.
202pub fn run(config: &InitConfig) -> Result<()> {
203    let project_dir = PathBuf::from(&config.project_name);
204
205    if project_dir.exists() {
206        anyhow::bail!(
207            "Directory '{}' already exists. Choose a different name or remove it first.",
208            config.project_name
209        );
210    }
211
212    info!("Creating project: {}", config.project_name);
213    println!("Creating FraiseQL project: {}", config.project_name);
214
215    // Create root directory
216    fs::create_dir_all(&project_dir)
217        .context(format!("Failed to create directory: {}", config.project_name))?;
218
219    // Create .gitignore
220    create_gitignore(&project_dir)?;
221
222    // Create fraiseql.toml
223    create_toml_config(&project_dir, config)?;
224
225    // Create schema.json
226    create_schema_json(&project_dir)?;
227
228    // Create database directory structure; keep the relative SQL paths so the
229    // printed next steps name the real files in apply order.
230    let sql_files = create_db_structure(&project_dir, config)?;
231
232    // Create language-specific authoring skeleton
233    skeletons::create_authoring_skeleton(&project_dir, config)?;
234
235    // Initialize git repository
236    if !config.no_git {
237        skeletons::init_git(&project_dir)?;
238    }
239
240    println!();
241    println!("Project created at ./{}", config.project_name);
242    println!();
243    println!("Set DATABASE_URL to your PostgreSQL connection URL first, e.g.:");
244    println!(
245        "  export DATABASE_URL=postgres://user:pass@localhost:5432/{}",
246        config.project_name
247    );
248    println!();
249    // Every command printed below must succeed, in order, on the project just
250    // generated — `init_first_run_pg.rs` executes them verbatim (#822).
251    println!("Next steps:");
252    println!("  cd {}", config.project_name);
253    println!("  fraiseql setup");
254    let file_args = sql_files.iter().map(|f| format!("-f {f}")).collect::<Vec<_>>().join(" ");
255    println!("  psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 {file_args}");
256    println!("  fraiseql compile schema.json");
257    println!("  fraiseql query '{{ posts {{ title }} }}'");
258    if !config.no_git {
259        println!("  git add -A && git commit -m \"Initial FraiseQL project\"");
260    }
261    println!();
262
263    Ok(())
264}
265
266fn create_gitignore(project_dir: &Path) -> Result<()> {
267    let content = "\
268# FraiseQL compiled output
269schema.compiled.json
270
271# Rust
272target/
273
274# Python
275__pycache__/
276*.pyc
277.venv/
278
279# TypeScript / Node
280node_modules/
281dist/
282
283# IDE
284.idea/
285.vscode/
286*.swp
287*.swo
288
289# OS
290.DS_Store
291Thumbs.db
292
293# Environment
294.env
295.env.local
296";
297    fs::write(project_dir.join(".gitignore"), content).context("Failed to create .gitignore")?;
298    info!("Created .gitignore");
299    Ok(())
300}
301
302fn create_toml_config(project_dir: &Path, config: &InitConfig) -> Result<()> {
303    let db_url = config.database.default_url(&config.project_name);
304    let db_target = config.database.toml_target();
305
306    let content = format!(
307        r#"[project]
308name = "{name}"
309version = "0.1.0"
310description = "A FraiseQL project"
311database_target = "{db_target}"
312
313[fraiseql]
314schema_file = "schema.json"
315output_file = "schema.compiled.json"
316
317[fraiseql.security.rate_limiting]
318enabled = true
319
320[fraiseql.security.audit_logging]
321enabled = true
322
323# Database connection URL — set via DATABASE_URL environment variable at runtime
324# {db_url}
325"#,
326        name = config.project_name,
327    );
328
329    fs::write(project_dir.join("fraiseql.toml"), content)
330        .context("Failed to create fraiseql.toml")?;
331    info!("Created fraiseql.toml");
332    Ok(())
333}
334
335fn create_schema_json(project_dir: &Path) -> Result<()> {
336    // IntermediateSchema format: arrays of typed objects
337    // Blog project: Author, Post, Comment, Tag
338    let schema = serde_json::json!({
339        "version": "2.0.0",
340        "types": [
341            {
342                "name": "Author",
343                "description": "Blog author",
344                "fields": [
345                    { "name": "pk", "type": "Int", "nullable": false, "description": "Internal primary key" },
346                    { "name": "id", "type": "ID", "nullable": false, "description": "Public UUID" },
347                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
348                    { "name": "name", "type": "String", "nullable": false },
349                    { "name": "email", "type": "String", "nullable": false },
350                    { "name": "bio", "type": "String", "nullable": true },
351                    { "name": "createdAt", "type": "DateTime", "nullable": false },
352                    { "name": "updatedAt", "type": "DateTime", "nullable": false }
353                ]
354            },
355            {
356                "name": "Post",
357                "description": "Blog post",
358                "fields": [
359                    { "name": "pk", "type": "Int", "nullable": false },
360                    { "name": "id", "type": "ID", "nullable": false },
361                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
362                    { "name": "title", "type": "String", "nullable": false },
363                    { "name": "body", "type": "String", "nullable": false },
364                    { "name": "published", "type": "Boolean", "nullable": false },
365                    { "name": "authorId", "type": "ID", "nullable": false },
366                    { "name": "createdAt", "type": "DateTime", "nullable": false },
367                    { "name": "updatedAt", "type": "DateTime", "nullable": false }
368                ]
369            },
370            {
371                "name": "Comment",
372                "description": "Comment on a blog post",
373                "fields": [
374                    { "name": "pk", "type": "Int", "nullable": false },
375                    { "name": "id", "type": "ID", "nullable": false },
376                    { "name": "body", "type": "String", "nullable": false },
377                    { "name": "authorName", "type": "String", "nullable": false },
378                    { "name": "postId", "type": "ID", "nullable": false },
379                    { "name": "createdAt", "type": "DateTime", "nullable": false }
380                ]
381            },
382            {
383                "name": "Tag",
384                "description": "Categorization tag for posts",
385                "fields": [
386                    { "name": "pk", "type": "Int", "nullable": false },
387                    { "name": "id", "type": "ID", "nullable": false },
388                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
389                    { "name": "name", "type": "String", "nullable": false }
390                ]
391            }
392        ],
393        "queries": [
394            {
395                "name": "posts",
396                "return_type": "Post",
397                "returns_list": true,
398                "sql_source": "v_post",
399                "description": "List all published posts"
400            },
401            {
402                "name": "post",
403                "return_type": "Post",
404                "returns_list": false,
405                "nullable": true,
406                "sql_source": "v_post",
407                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
408            },
409            {
410                "name": "authors",
411                "return_type": "Author",
412                "returns_list": true,
413                "sql_source": "v_author"
414            },
415            {
416                "name": "author",
417                "return_type": "Author",
418                "returns_list": false,
419                "nullable": true,
420                "sql_source": "v_author",
421                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
422            },
423            {
424                "name": "tags",
425                "return_type": "Tag",
426                "returns_list": true,
427                "sql_source": "v_tag"
428            }
429        ],
430        "mutations": [
431            {
432                "name": "createAuthor",
433                "return_type": "Author",
434                "sql_source": "fn_author_create",
435                "operation": "CREATE",
436                "description": "Create a blog author",
437                "arguments": [
438                    { "name": "identifier", "type": "String", "nullable": false },
439                    { "name": "name", "type": "String", "nullable": false },
440                    { "name": "email", "type": "String", "nullable": false },
441                    { "name": "bio", "type": "String", "nullable": true }
442                ]
443            },
444            {
445                "name": "deleteAuthor",
446                "return_type": "Author",
447                "sql_source": "fn_author_delete",
448                "operation": "DELETE",
449                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
450            },
451            {
452                "name": "createPost",
453                "return_type": "Post",
454                "sql_source": "fn_post_create",
455                "operation": "CREATE",
456                "description": "Create a blog post (unpublished)",
457                "arguments": [
458                    { "name": "identifier", "type": "String", "nullable": false },
459                    { "name": "title", "type": "String", "nullable": false },
460                    { "name": "body", "type": "String", "nullable": false },
461                    { "name": "authorId", "type": "ID", "nullable": false }
462                ]
463            },
464            {
465                "name": "publishPost",
466                "return_type": "Post",
467                "sql_source": "fn_post_publish",
468                "operation": "UPDATE",
469                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
470            },
471            {
472                "name": "deletePost",
473                "return_type": "Post",
474                "sql_source": "fn_post_delete",
475                "operation": "DELETE",
476                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
477            },
478            {
479                "name": "createComment",
480                "return_type": "Comment",
481                "sql_source": "fn_comment_create",
482                "operation": "CREATE",
483                "arguments": [
484                    { "name": "body", "type": "String", "nullable": false },
485                    { "name": "authorName", "type": "String", "nullable": false },
486                    { "name": "postId", "type": "ID", "nullable": false }
487                ]
488            },
489            {
490                "name": "deleteComment",
491                "return_type": "Comment",
492                "sql_source": "fn_comment_delete",
493                "operation": "DELETE",
494                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
495            },
496            {
497                "name": "createTag",
498                "return_type": "Tag",
499                "sql_source": "fn_tag_create",
500                "operation": "CREATE",
501                "arguments": [
502                    { "name": "identifier", "type": "String", "nullable": false },
503                    { "name": "name", "type": "String", "nullable": false }
504                ]
505            },
506            {
507                "name": "deleteTag",
508                "return_type": "Tag",
509                "sql_source": "fn_tag_delete",
510                "operation": "DELETE",
511                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
512            }
513        ],
514        "enums": [],
515        "input_types": [],
516        "interfaces": [],
517        "unions": [],
518        "subscriptions": []
519    });
520
521    let content = serde_json::to_string_pretty(&schema).context("Failed to serialize schema")?;
522    fs::write(project_dir.join("schema.json"), content).context("Failed to create schema.json")?;
523    info!("Created schema.json");
524    Ok(())
525}
526
527/// Write the DDL files for the chosen layout. Returns the project-relative
528/// paths **in apply order** (write → read → functions, entities in dependency
529/// order) so the printed `psql` next step applies them correctly.
530fn create_db_structure(project_dir: &Path, config: &InitConfig) -> Result<Vec<String>> {
531    match config.size {
532        ProjectSize::Xs => create_db_xs(project_dir),
533        ProjectSize::S => create_db_s(project_dir),
534        ProjectSize::M => create_db_m(project_dir),
535    }
536}
537
538fn create_db_xs(project_dir: &Path) -> Result<Vec<String>> {
539    let db_dir = project_dir.join("db").join("0_schema");
540    fs::create_dir_all(&db_dir).context("Failed to create db/0_schema")?;
541
542    let content = sql_templates::generate_single_schema_sql();
543    fs::write(db_dir.join("schema.sql"), content).context("Failed to create schema.sql")?;
544    info!("Created db/0_schema/schema.sql (xs layout)");
545    Ok(vec!["db/0_schema/schema.sql".to_string()])
546}
547
548fn create_db_s(project_dir: &Path) -> Result<Vec<String>> {
549    let schema_dir = project_dir.join("db").join("0_schema");
550    let write_dir = schema_dir.join("01_write");
551    let read_dir = schema_dir.join("02_read");
552    let functions_dir = schema_dir.join("03_functions");
553
554    fs::create_dir_all(&write_dir).context("Failed to create 01_write")?;
555    fs::create_dir_all(&read_dir).context("Failed to create 02_read")?;
556    fs::create_dir_all(&functions_dir).context("Failed to create 03_functions")?;
557
558    let mut written = [Vec::new(), Vec::new(), Vec::new()];
559    for (i, entity) in ENTITIES.iter().enumerate() {
560        let n = i + 1;
561        let (table_sql, view_sql, fn_sql) = sql_templates::generate_blog_entity_sql(entity);
562        let table_rel = format!("db/0_schema/01_write/01{n}_tb_{entity}.sql");
563        fs::write(write_dir.join(format!("01{n}_tb_{entity}.sql")), table_sql)
564            .context(format!("Failed to create tb_{entity}.sql"))?;
565        written[0].push(table_rel);
566        let view_rel = format!("db/0_schema/02_read/02{n}_v_{entity}.sql");
567        fs::write(read_dir.join(format!("02{n}_v_{entity}.sql")), view_sql)
568            .context(format!("Failed to create v_{entity}.sql"))?;
569        written[1].push(view_rel);
570        let fn_rel = format!("db/0_schema/03_functions/03{n}_fn_{entity}_crud.sql");
571        fs::write(functions_dir.join(format!("03{n}_fn_{entity}_crud.sql")), fn_sql)
572            .context(format!("Failed to create fn_{entity}_crud.sql"))?;
573        written[2].push(fn_rel);
574    }
575
576    info!("Created db/0_schema/ (s layout)");
577    Ok(written.into_iter().flatten().collect())
578}
579
580fn create_db_m(project_dir: &Path) -> Result<Vec<String>> {
581    let schema_dir = project_dir.join("db").join("0_schema");
582
583    let mut written = [Vec::new(), Vec::new(), Vec::new()];
584    for entity in &ENTITIES {
585        let write_dir = schema_dir.join("01_write").join(entity);
586        let read_dir = schema_dir.join("02_read").join(entity);
587        let functions_dir = schema_dir.join("03_functions").join(entity);
588
589        fs::create_dir_all(&write_dir).context(format!("Failed to create 01_write/{entity}"))?;
590        fs::create_dir_all(&read_dir).context(format!("Failed to create 02_read/{entity}"))?;
591        fs::create_dir_all(&functions_dir)
592            .context(format!("Failed to create 03_functions/{entity}"))?;
593
594        let (table_sql, view_sql, fn_sql) = sql_templates::generate_blog_entity_sql(entity);
595        fs::write(write_dir.join(format!("tb_{entity}.sql")), table_sql)
596            .context(format!("Failed to create tb_{entity}.sql"))?;
597        written[0].push(format!("db/0_schema/01_write/{entity}/tb_{entity}.sql"));
598        fs::write(read_dir.join(format!("v_{entity}.sql")), view_sql)
599            .context(format!("Failed to create v_{entity}.sql"))?;
600        written[1].push(format!("db/0_schema/02_read/{entity}/v_{entity}.sql"));
601        fs::write(functions_dir.join(format!("fn_{entity}_crud.sql")), fn_sql)
602            .context(format!("Failed to create fn_{entity}_crud.sql"))?;
603        written[2].push(format!("db/0_schema/03_functions/{entity}/fn_{entity}_crud.sql"));
604    }
605
606    info!("Created db/0_schema/ (m layout)");
607    Ok(written.into_iter().flatten().collect())
608}
609
610#[cfg(test)]
611mod tests;