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
319auth_start_max_requests = 100
320auth_start_window_secs = 60
321
322[fraiseql.security.audit_logging]
323enabled = true
324
325# Database connection URL — set via DATABASE_URL environment variable at runtime
326# {db_url}
327"#,
328        name = config.project_name,
329    );
330
331    fs::write(project_dir.join("fraiseql.toml"), content)
332        .context("Failed to create fraiseql.toml")?;
333    info!("Created fraiseql.toml");
334    Ok(())
335}
336
337fn create_schema_json(project_dir: &Path) -> Result<()> {
338    // IntermediateSchema format: arrays of typed objects
339    // Blog project: Author, Post, Comment, Tag
340    let schema = serde_json::json!({
341        "version": "2.0.0",
342        "types": [
343            {
344                "name": "Author",
345                "description": "Blog author",
346                "fields": [
347                    { "name": "pk", "type": "Int", "nullable": false, "description": "Internal primary key" },
348                    { "name": "id", "type": "ID", "nullable": false, "description": "Public UUID" },
349                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
350                    { "name": "name", "type": "String", "nullable": false },
351                    { "name": "email", "type": "String", "nullable": false },
352                    { "name": "bio", "type": "String", "nullable": true },
353                    { "name": "createdAt", "type": "DateTime", "nullable": false },
354                    { "name": "updatedAt", "type": "DateTime", "nullable": false }
355                ]
356            },
357            {
358                "name": "Post",
359                "description": "Blog post",
360                "fields": [
361                    { "name": "pk", "type": "Int", "nullable": false },
362                    { "name": "id", "type": "ID", "nullable": false },
363                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
364                    { "name": "title", "type": "String", "nullable": false },
365                    { "name": "body", "type": "String", "nullable": false },
366                    { "name": "published", "type": "Boolean", "nullable": false },
367                    { "name": "authorId", "type": "ID", "nullable": false },
368                    { "name": "createdAt", "type": "DateTime", "nullable": false },
369                    { "name": "updatedAt", "type": "DateTime", "nullable": false }
370                ]
371            },
372            {
373                "name": "Comment",
374                "description": "Comment on a blog post",
375                "fields": [
376                    { "name": "pk", "type": "Int", "nullable": false },
377                    { "name": "id", "type": "ID", "nullable": false },
378                    { "name": "body", "type": "String", "nullable": false },
379                    { "name": "authorName", "type": "String", "nullable": false },
380                    { "name": "postId", "type": "ID", "nullable": false },
381                    { "name": "createdAt", "type": "DateTime", "nullable": false }
382                ]
383            },
384            {
385                "name": "Tag",
386                "description": "Categorization tag for posts",
387                "fields": [
388                    { "name": "pk", "type": "Int", "nullable": false },
389                    { "name": "id", "type": "ID", "nullable": false },
390                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
391                    { "name": "name", "type": "String", "nullable": false }
392                ]
393            }
394        ],
395        "queries": [
396            {
397                "name": "posts",
398                "return_type": "Post",
399                "returns_list": true,
400                "sql_source": "v_post",
401                "description": "List all published posts"
402            },
403            {
404                "name": "post",
405                "return_type": "Post",
406                "returns_list": false,
407                "nullable": true,
408                "sql_source": "v_post",
409                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
410            },
411            {
412                "name": "authors",
413                "return_type": "Author",
414                "returns_list": true,
415                "sql_source": "v_author"
416            },
417            {
418                "name": "author",
419                "return_type": "Author",
420                "returns_list": false,
421                "nullable": true,
422                "sql_source": "v_author",
423                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
424            },
425            {
426                "name": "tags",
427                "return_type": "Tag",
428                "returns_list": true,
429                "sql_source": "v_tag"
430            }
431        ],
432        "mutations": [
433            {
434                "name": "createAuthor",
435                "return_type": "Author",
436                "sql_source": "fn_author_create",
437                "operation": "CREATE",
438                "description": "Create a blog author",
439                "arguments": [
440                    { "name": "identifier", "type": "String", "nullable": false },
441                    { "name": "name", "type": "String", "nullable": false },
442                    { "name": "email", "type": "String", "nullable": false },
443                    { "name": "bio", "type": "String", "nullable": true }
444                ]
445            },
446            {
447                "name": "deleteAuthor",
448                "return_type": "Author",
449                "sql_source": "fn_author_delete",
450                "operation": "DELETE",
451                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
452            },
453            {
454                "name": "createPost",
455                "return_type": "Post",
456                "sql_source": "fn_post_create",
457                "operation": "CREATE",
458                "description": "Create a blog post (unpublished)",
459                "arguments": [
460                    { "name": "identifier", "type": "String", "nullable": false },
461                    { "name": "title", "type": "String", "nullable": false },
462                    { "name": "body", "type": "String", "nullable": false },
463                    { "name": "authorId", "type": "ID", "nullable": false }
464                ]
465            },
466            {
467                "name": "publishPost",
468                "return_type": "Post",
469                "sql_source": "fn_post_publish",
470                "operation": "UPDATE",
471                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
472            },
473            {
474                "name": "deletePost",
475                "return_type": "Post",
476                "sql_source": "fn_post_delete",
477                "operation": "DELETE",
478                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
479            },
480            {
481                "name": "createComment",
482                "return_type": "Comment",
483                "sql_source": "fn_comment_create",
484                "operation": "CREATE",
485                "arguments": [
486                    { "name": "body", "type": "String", "nullable": false },
487                    { "name": "authorName", "type": "String", "nullable": false },
488                    { "name": "postId", "type": "ID", "nullable": false }
489                ]
490            },
491            {
492                "name": "deleteComment",
493                "return_type": "Comment",
494                "sql_source": "fn_comment_delete",
495                "operation": "DELETE",
496                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
497            },
498            {
499                "name": "createTag",
500                "return_type": "Tag",
501                "sql_source": "fn_tag_create",
502                "operation": "CREATE",
503                "arguments": [
504                    { "name": "identifier", "type": "String", "nullable": false },
505                    { "name": "name", "type": "String", "nullable": false }
506                ]
507            },
508            {
509                "name": "deleteTag",
510                "return_type": "Tag",
511                "sql_source": "fn_tag_delete",
512                "operation": "DELETE",
513                "arguments": [{ "name": "id", "type": "ID", "nullable": false }]
514            }
515        ],
516        "enums": [],
517        "input_types": [],
518        "interfaces": [],
519        "unions": [],
520        "subscriptions": []
521    });
522
523    let content = serde_json::to_string_pretty(&schema).context("Failed to serialize schema")?;
524    fs::write(project_dir.join("schema.json"), content).context("Failed to create schema.json")?;
525    info!("Created schema.json");
526    Ok(())
527}
528
529/// Write the DDL files for the chosen layout. Returns the project-relative
530/// paths **in apply order** (write → read → functions, entities in dependency
531/// order) so the printed `psql` next step applies them correctly.
532fn create_db_structure(project_dir: &Path, config: &InitConfig) -> Result<Vec<String>> {
533    match config.size {
534        ProjectSize::Xs => create_db_xs(project_dir),
535        ProjectSize::S => create_db_s(project_dir),
536        ProjectSize::M => create_db_m(project_dir),
537    }
538}
539
540fn create_db_xs(project_dir: &Path) -> Result<Vec<String>> {
541    let db_dir = project_dir.join("db").join("0_schema");
542    fs::create_dir_all(&db_dir).context("Failed to create db/0_schema")?;
543
544    let content = sql_templates::generate_single_schema_sql();
545    fs::write(db_dir.join("schema.sql"), content).context("Failed to create schema.sql")?;
546    info!("Created db/0_schema/schema.sql (xs layout)");
547    Ok(vec!["db/0_schema/schema.sql".to_string()])
548}
549
550fn create_db_s(project_dir: &Path) -> Result<Vec<String>> {
551    let schema_dir = project_dir.join("db").join("0_schema");
552    let write_dir = schema_dir.join("01_write");
553    let read_dir = schema_dir.join("02_read");
554    let functions_dir = schema_dir.join("03_functions");
555
556    fs::create_dir_all(&write_dir).context("Failed to create 01_write")?;
557    fs::create_dir_all(&read_dir).context("Failed to create 02_read")?;
558    fs::create_dir_all(&functions_dir).context("Failed to create 03_functions")?;
559
560    let mut written = [Vec::new(), Vec::new(), Vec::new()];
561    for (i, entity) in ENTITIES.iter().enumerate() {
562        let n = i + 1;
563        let (table_sql, view_sql, fn_sql) = sql_templates::generate_blog_entity_sql(entity);
564        let table_rel = format!("db/0_schema/01_write/01{n}_tb_{entity}.sql");
565        fs::write(write_dir.join(format!("01{n}_tb_{entity}.sql")), table_sql)
566            .context(format!("Failed to create tb_{entity}.sql"))?;
567        written[0].push(table_rel);
568        let view_rel = format!("db/0_schema/02_read/02{n}_v_{entity}.sql");
569        fs::write(read_dir.join(format!("02{n}_v_{entity}.sql")), view_sql)
570            .context(format!("Failed to create v_{entity}.sql"))?;
571        written[1].push(view_rel);
572        let fn_rel = format!("db/0_schema/03_functions/03{n}_fn_{entity}_crud.sql");
573        fs::write(functions_dir.join(format!("03{n}_fn_{entity}_crud.sql")), fn_sql)
574            .context(format!("Failed to create fn_{entity}_crud.sql"))?;
575        written[2].push(fn_rel);
576    }
577
578    info!("Created db/0_schema/ (s layout)");
579    Ok(written.into_iter().flatten().collect())
580}
581
582fn create_db_m(project_dir: &Path) -> Result<Vec<String>> {
583    let schema_dir = project_dir.join("db").join("0_schema");
584
585    let mut written = [Vec::new(), Vec::new(), Vec::new()];
586    for entity in &ENTITIES {
587        let write_dir = schema_dir.join("01_write").join(entity);
588        let read_dir = schema_dir.join("02_read").join(entity);
589        let functions_dir = schema_dir.join("03_functions").join(entity);
590
591        fs::create_dir_all(&write_dir).context(format!("Failed to create 01_write/{entity}"))?;
592        fs::create_dir_all(&read_dir).context(format!("Failed to create 02_read/{entity}"))?;
593        fs::create_dir_all(&functions_dir)
594            .context(format!("Failed to create 03_functions/{entity}"))?;
595
596        let (table_sql, view_sql, fn_sql) = sql_templates::generate_blog_entity_sql(entity);
597        fs::write(write_dir.join(format!("tb_{entity}.sql")), table_sql)
598            .context(format!("Failed to create tb_{entity}.sql"))?;
599        written[0].push(format!("db/0_schema/01_write/{entity}/tb_{entity}.sql"));
600        fs::write(read_dir.join(format!("v_{entity}.sql")), view_sql)
601            .context(format!("Failed to create v_{entity}.sql"))?;
602        written[1].push(format!("db/0_schema/02_read/{entity}/v_{entity}.sql"));
603        fs::write(functions_dir.join(format!("fn_{entity}_crud.sql")), fn_sql)
604            .context(format!("Failed to create fn_{entity}_crud.sql"))?;
605        written[2].push(format!("db/0_schema/03_functions/{entity}/fn_{entity}_crud.sql"));
606    }
607
608    info!("Created db/0_schema/ (m layout)");
609    Ok(written.into_iter().flatten().collect())
610}
611
612#[cfg(test)]
613mod tests;