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/// Supported databases
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum Database {
106    /// PostgreSQL (primary)
107    Postgres,
108    /// MySQL
109    Mysql,
110    /// SQLite
111    Sqlite,
112    /// SQL Server
113    SqlServer,
114}
115
116impl fmt::Display for Database {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::Postgres => write!(f, "postgres"),
120            Self::Mysql => write!(f, "mysql"),
121            Self::Sqlite => write!(f, "sqlite"),
122            Self::SqlServer => write!(f, "sqlserver"),
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            "mysql" => Ok(Self::Mysql),
134            "sqlite" => Ok(Self::Sqlite),
135            "sqlserver" | "mssql" => Ok(Self::SqlServer),
136            other => Err(format!(
137                "Unknown database: {other}. Choose: postgres, mysql, sqlite, sqlserver"
138            )),
139        }
140    }
141}
142
143/// Database target string for fraiseql.toml
144impl Database {
145    const fn toml_target(self) -> &'static str {
146        match self {
147            Self::Postgres => "postgresql",
148            Self::Mysql => "mysql",
149            Self::Sqlite => "sqlite",
150            Self::SqlServer => "sqlserver",
151        }
152    }
153
154    fn default_url(self, project_name: &str) -> String {
155        match self {
156            Self::Postgres => format!("postgresql://localhost/{project_name}"),
157            Self::Mysql => format!("mysql://localhost/{project_name}"),
158            Self::Sqlite => format!("{project_name}.db"),
159            Self::SqlServer => format!("mssql://localhost/{project_name}"),
160        }
161    }
162}
163
164/// Project size determines directory granularity
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum ProjectSize {
168    /// Single `schema.sql` file
169    Xs,
170    /// Flat numbered directories (01_write, 02_read, 03_functions)
171    S,
172    /// Per-entity subdirectories under each numbered directory
173    M,
174}
175
176impl FromStr for ProjectSize {
177    type Err = String;
178
179    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
180        match s.to_lowercase().as_str() {
181            "xs" => Ok(Self::Xs),
182            "s" => Ok(Self::S),
183            "m" => Ok(Self::M),
184            other => Err(format!("Unknown size: {other}. Choose: xs, s, m")),
185        }
186    }
187}
188
189/// Configuration for the init command
190pub struct InitConfig {
191    /// Name of the project (used as directory name)
192    pub project_name: String,
193    /// Authoring language for the schema skeleton
194    pub language:     Language,
195    /// Target database engine
196    pub database:     Database,
197    /// Project size (directory granularity)
198    pub size:         ProjectSize,
199    /// Skip git initialization
200    pub no_git:       bool,
201}
202
203/// Run the init command
204///
205/// # Errors
206///
207/// Returns an error if the target directory already exists, if any file or
208/// directory creation fails, or if the language skeleton cannot be written.
209pub fn run(config: &InitConfig) -> Result<()> {
210    let project_dir = PathBuf::from(&config.project_name);
211
212    if project_dir.exists() {
213        anyhow::bail!(
214            "Directory '{}' already exists. Choose a different name or remove it first.",
215            config.project_name
216        );
217    }
218
219    info!("Creating project: {}", config.project_name);
220    println!("Creating FraiseQL project: {}", config.project_name);
221
222    // Create root directory
223    fs::create_dir_all(&project_dir)
224        .context(format!("Failed to create directory: {}", config.project_name))?;
225
226    // Create .gitignore
227    create_gitignore(&project_dir)?;
228
229    // Create fraiseql.toml
230    create_toml_config(&project_dir, config)?;
231
232    // Create schema.json
233    create_schema_json(&project_dir)?;
234
235    // Create database directory structure
236    create_db_structure(&project_dir, config)?;
237
238    // Create language-specific authoring skeleton
239    skeletons::create_authoring_skeleton(&project_dir, config)?;
240
241    // Initialize git repository
242    if !config.no_git {
243        skeletons::init_git(&project_dir)?;
244    }
245
246    println!();
247    println!("Project created at ./{}", config.project_name);
248    println!();
249    println!("Next steps:");
250    println!("  cd {}", config.project_name);
251    println!("  fraiseql compile fraiseql.toml");
252    if !config.no_git {
253        println!("  git add -A && git commit -m \"Initial FraiseQL project\"");
254    }
255    println!();
256
257    Ok(())
258}
259
260fn create_gitignore(project_dir: &Path) -> Result<()> {
261    let content = "\
262# FraiseQL compiled output
263schema.compiled.json
264
265# Rust
266target/
267
268# Python
269__pycache__/
270*.pyc
271.venv/
272
273# TypeScript / Node
274node_modules/
275dist/
276
277# IDE
278.idea/
279.vscode/
280*.swp
281*.swo
282
283# OS
284.DS_Store
285Thumbs.db
286
287# Environment
288.env
289.env.local
290";
291    fs::write(project_dir.join(".gitignore"), content).context("Failed to create .gitignore")?;
292    info!("Created .gitignore");
293    Ok(())
294}
295
296fn create_toml_config(project_dir: &Path, config: &InitConfig) -> Result<()> {
297    let db_url = config.database.default_url(&config.project_name);
298    let db_target = config.database.toml_target();
299
300    let content = format!(
301        r#"[project]
302name = "{name}"
303version = "0.1.0"
304description = "A FraiseQL project"
305database_target = "{db_target}"
306
307[fraiseql]
308schema_file = "schema.json"
309output_file = "schema.compiled.json"
310
311[fraiseql.security.rate_limiting]
312enabled = true
313auth_start_max_requests = 100
314auth_start_window_secs = 60
315
316[fraiseql.security.audit_logging]
317enabled = true
318log_level = "info"
319
320# Database connection URL — set via DATABASE_URL environment variable at runtime
321# {db_url}
322"#,
323        name = config.project_name,
324    );
325
326    fs::write(project_dir.join("fraiseql.toml"), content)
327        .context("Failed to create fraiseql.toml")?;
328    info!("Created fraiseql.toml");
329    Ok(())
330}
331
332fn create_schema_json(project_dir: &Path) -> Result<()> {
333    // IntermediateSchema format: arrays of typed objects
334    // Blog project: Author, Post, Comment, Tag
335    let schema = serde_json::json!({
336        "version": "2.0.0",
337        "types": [
338            {
339                "name": "Author",
340                "description": "Blog author",
341                "fields": [
342                    { "name": "pk", "type": "Int", "nullable": false, "description": "Internal primary key" },
343                    { "name": "id", "type": "ID", "nullable": false, "description": "Public UUID" },
344                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
345                    { "name": "name", "type": "String", "nullable": false },
346                    { "name": "email", "type": "String", "nullable": false },
347                    { "name": "bio", "type": "String", "nullable": true },
348                    { "name": "created_at", "type": "DateTime", "nullable": false },
349                    { "name": "updated_at", "type": "DateTime", "nullable": false }
350                ]
351            },
352            {
353                "name": "Post",
354                "description": "Blog post",
355                "fields": [
356                    { "name": "pk", "type": "Int", "nullable": false },
357                    { "name": "id", "type": "ID", "nullable": false },
358                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
359                    { "name": "title", "type": "String", "nullable": false },
360                    { "name": "body", "type": "String", "nullable": false },
361                    { "name": "published", "type": "Boolean", "nullable": false },
362                    { "name": "author_id", "type": "ID", "nullable": false },
363                    { "name": "created_at", "type": "DateTime", "nullable": false },
364                    { "name": "updated_at", "type": "DateTime", "nullable": false }
365                ]
366            },
367            {
368                "name": "Comment",
369                "description": "Comment on a blog post",
370                "fields": [
371                    { "name": "pk", "type": "Int", "nullable": false },
372                    { "name": "id", "type": "ID", "nullable": false },
373                    { "name": "body", "type": "String", "nullable": false },
374                    { "name": "author_name", "type": "String", "nullable": false },
375                    { "name": "post_id", "type": "ID", "nullable": false },
376                    { "name": "created_at", "type": "DateTime", "nullable": false }
377                ]
378            },
379            {
380                "name": "Tag",
381                "description": "Categorization tag for posts",
382                "fields": [
383                    { "name": "pk", "type": "Int", "nullable": false },
384                    { "name": "id", "type": "ID", "nullable": false },
385                    { "name": "identifier", "type": "String", "nullable": false, "description": "URL slug" },
386                    { "name": "name", "type": "String", "nullable": false }
387                ]
388            }
389        ],
390        "queries": [
391            {
392                "name": "posts",
393                "return_type": "Post",
394                "return_array": true,
395                "sql_source": "v_post",
396                "description": "List all published posts"
397            },
398            {
399                "name": "post",
400                "return_type": "Post",
401                "return_array": false,
402                "sql_source": "v_post",
403                "args": [{ "name": "id", "type": "ID", "required": true }]
404            },
405            {
406                "name": "authors",
407                "return_type": "Author",
408                "return_array": true,
409                "sql_source": "v_author"
410            },
411            {
412                "name": "author",
413                "return_type": "Author",
414                "return_array": false,
415                "sql_source": "v_author",
416                "args": [{ "name": "id", "type": "ID", "required": true }]
417            },
418            {
419                "name": "tags",
420                "return_type": "Tag",
421                "return_array": true,
422                "sql_source": "v_tag"
423            }
424        ],
425        "mutations": [],
426        "enums": [],
427        "input_types": [],
428        "interfaces": [],
429        "unions": [],
430        "subscriptions": []
431    });
432
433    let content = serde_json::to_string_pretty(&schema).context("Failed to serialize schema")?;
434    fs::write(project_dir.join("schema.json"), content).context("Failed to create schema.json")?;
435    info!("Created schema.json");
436    Ok(())
437}
438
439fn create_db_structure(project_dir: &Path, config: &InitConfig) -> Result<()> {
440    match config.size {
441        ProjectSize::Xs => create_db_xs(project_dir, config),
442        ProjectSize::S => create_db_s(project_dir, config),
443        ProjectSize::M => create_db_m(project_dir, config),
444    }
445}
446
447fn create_db_xs(project_dir: &Path, config: &InitConfig) -> Result<()> {
448    let db_dir = project_dir.join("db").join("0_schema");
449    fs::create_dir_all(&db_dir).context("Failed to create db/0_schema")?;
450
451    let content = sql_templates::generate_single_schema_sql(config.database);
452    fs::write(db_dir.join("schema.sql"), content).context("Failed to create schema.sql")?;
453    info!("Created db/0_schema/schema.sql (xs layout)");
454    Ok(())
455}
456
457fn create_db_s(project_dir: &Path, config: &InitConfig) -> Result<()> {
458    let schema_dir = project_dir.join("db").join("0_schema");
459    let write_dir = schema_dir.join("01_write");
460    let read_dir = schema_dir.join("02_read");
461    let functions_dir = schema_dir.join("03_functions");
462
463    fs::create_dir_all(&write_dir).context("Failed to create 01_write")?;
464    fs::create_dir_all(&read_dir).context("Failed to create 02_read")?;
465    fs::create_dir_all(&functions_dir).context("Failed to create 03_functions")?;
466
467    // Blog entities: author, post, comment, tag (ordered by dependency)
468    let entities = ["author", "post", "comment", "tag"];
469    for (i, entity) in entities.iter().enumerate() {
470        let n = i + 1;
471        let (table_sql, view_sql, fn_sql) =
472            sql_templates::generate_blog_entity_sql(config.database, entity);
473        fs::write(write_dir.join(format!("01{n}_tb_{entity}.sql")), table_sql)
474            .context(format!("Failed to create tb_{entity}.sql"))?;
475        if !view_sql.is_empty() {
476            fs::write(read_dir.join(format!("02{n}_v_{entity}.sql")), view_sql)
477                .context(format!("Failed to create v_{entity}.sql"))?;
478        }
479        if !fn_sql.is_empty() {
480            fs::write(functions_dir.join(format!("03{n}_fn_{entity}_crud.sql")), fn_sql)
481                .context(format!("Failed to create fn_{entity}_crud.sql"))?;
482        }
483    }
484
485    info!("Created db/0_schema/ (s layout)");
486    Ok(())
487}
488
489fn create_db_m(project_dir: &Path, config: &InitConfig) -> Result<()> {
490    let schema_dir = project_dir.join("db").join("0_schema");
491
492    let entities = ["author", "post", "comment", "tag"];
493    for entity in &entities {
494        let write_dir = schema_dir.join("01_write").join(entity);
495        let read_dir = schema_dir.join("02_read").join(entity);
496        let functions_dir = schema_dir.join("03_functions").join(entity);
497
498        fs::create_dir_all(&write_dir).context(format!("Failed to create 01_write/{entity}"))?;
499        fs::create_dir_all(&read_dir).context(format!("Failed to create 02_read/{entity}"))?;
500        fs::create_dir_all(&functions_dir)
501            .context(format!("Failed to create 03_functions/{entity}"))?;
502
503        let (table_sql, view_sql, fn_sql) =
504            sql_templates::generate_blog_entity_sql(config.database, entity);
505        fs::write(write_dir.join(format!("tb_{entity}.sql")), table_sql)
506            .context(format!("Failed to create tb_{entity}.sql"))?;
507        if !view_sql.is_empty() {
508            fs::write(read_dir.join(format!("v_{entity}.sql")), view_sql)
509                .context(format!("Failed to create v_{entity}.sql"))?;
510        }
511        if !fn_sql.is_empty() {
512            fs::write(functions_dir.join(format!("fn_{entity}_crud.sql")), fn_sql)
513                .context(format!("Failed to create fn_{entity}_crud.sql"))?;
514        }
515    }
516
517    info!("Created db/0_schema/ (m layout)");
518    Ok(())
519}
520
521#[cfg(test)]
522mod tests;