1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Language {
22 Python,
24 TypeScript,
26 Rust,
28 Java,
30 Kotlin,
32 Go,
34 CSharp,
36 Swift,
38 Scala,
40 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum Database {
106 Postgres,
108 Mysql,
110 Sqlite,
112 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
143impl 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum ProjectSize {
168 Xs,
170 S,
172 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
189pub struct InitConfig {
191 pub project_name: String,
193 pub language: Language,
195 pub database: Database,
197 pub size: ProjectSize,
199 pub no_git: bool,
201}
202
203pub 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 fs::create_dir_all(&project_dir)
224 .context(format!("Failed to create directory: {}", config.project_name))?;
225
226 create_gitignore(&project_dir)?;
228
229 create_toml_config(&project_dir, config)?;
231
232 create_schema_json(&project_dir)?;
234
235 create_db_structure(&project_dir, config)?;
237
238 skeletons::create_authoring_skeleton(&project_dir, config)?;
240
241 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 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 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;