Skip to main content

craken_cli/
generate.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5
6use anyhow::{Context, Result};
7
8// ── Name utilities ────────────────────────────────────────────────────────────
9
10/// `UserController` → `user_controller`
11pub fn pascal_to_snake(name: &str) -> String {
12    let mut out = String::new();
13    for (i, ch) in name.char_indices() {
14        if ch.is_uppercase() && i > 0 {
15            out.push('_');
16        }
17        out.extend(ch.to_lowercase());
18    }
19    out
20}
21
22// ── Shared helpers ────────────────────────────────────────────────────────────
23
24/// Ensure `dir` exists and append `pub mod <module_name>;` to `dir/mod.rs`
25/// (creating the file if absent, skipping if the line already exists).
26fn register_in_mod(dir: &Path, module_name: &str) -> Result<()> {
27    fs::create_dir_all(dir)?;
28
29    let mod_path = dir.join("mod.rs");
30    let declaration = format!("pub mod {module_name};");
31
32    let existing = if mod_path.exists() {
33        fs::read_to_string(&mod_path).with_context(|| format!("Failed to read {mod_path:?}"))?
34    } else {
35        String::new()
36    };
37
38    if existing.contains(&declaration) {
39        return Ok(()); // already registered
40    }
41
42    let mut updated = existing;
43    if !updated.is_empty() && !updated.ends_with('\n') {
44        updated.push('\n');
45    }
46    updated.push_str(&declaration);
47    updated.push('\n');
48
49    fs::write(&mod_path, &updated).with_context(|| format!("Failed to write {mod_path:?}"))?;
50
51    Ok(())
52}
53
54// ── `craken make controller <Name>` ──────────────────────────────────────────
55
56/// Scaffold `src/controllers/<snake_name>.rs` and register it in `src/controllers/mod.rs`.
57pub fn make_controller(name: &str) -> Result<()> {
58    let snake = pascal_to_snake(name);
59    let dir = Path::new("src/controllers");
60    fs::create_dir_all(dir)?;
61
62    let file = dir.join(format!("{snake}.rs"));
63    if file.exists() {
64        anyhow::bail!("Controller already exists: {file:?}");
65    }
66
67    let template = format!(
68        r#"use axum::{{extract::Path, Json}};
69use craken_http::{{CrakenError, Inject, RequestContext}};
70use craken_macros::{{controller, delete, get, post, put}};
71use serde::{{Deserialize, Serialize}};
72
73// ── Replace with your domain types ───────────────────────────────────────────
74
75#[derive(Serialize)]
76pub struct Item {{
77    pub id: u64,
78}}
79
80pub struct {name};
81
82// ── Route handlers ────────────────────────────────────────────────────────────
83//
84// Methods annotated with #[get], #[post], etc. must NOT take a `self`
85// receiver — use Inject<T> or RequestContext to access services.
86//
87// Mount this controller:
88//   HttpServer::new().configure_routes(&controllers::{snake}::{name})
89
90#[controller]
91impl {name} {{
92    /// GET /
93    #[get("/")]
94    pub async fn index() -> Result<Json<Vec<Item>>, CrakenError> {{
95        Ok(Json(vec![]))
96    }}
97
98    /// GET /:id
99    #[get("/:id")]
100    pub async fn show(Path(id): Path<u64>) -> Result<Json<Item>, CrakenError> {{
101        Ok(Json(Item {{ id }}))
102    }}
103}}
104"#
105    );
106
107    fs::write(&file, &template).with_context(|| format!("Failed to write {file:?}"))?;
108    register_in_mod(dir, &snake)?;
109
110    println!("✓  src/controllers/{snake}.rs");
111    println!("   Register: HttpServer::new().configure_routes(&controllers::{snake}::{name})");
112    Ok(())
113}
114
115// ── `craken make service <Name>` ─────────────────────────────────────────────
116
117/// Scaffold `src/services/<snake_name>.rs` and register it in `src/services/mod.rs`.
118pub fn make_service(name: &str) -> Result<()> {
119    let snake = pascal_to_snake(name);
120    let dir = Path::new("src/services");
121    fs::create_dir_all(dir)?;
122
123    let file = dir.join(format!("{snake}.rs"));
124    if file.exists() {
125        anyhow::bail!("Service already exists: {file:?}");
126    }
127
128    let template = format!(
129        r#"/// {name} — application-layer service.
130///
131/// Register as a singleton:
132/// ```rust,ignore
133/// container.register({name}::new());
134/// ```
135///
136/// Register as scoped (one instance per request):
137/// ```rust,ignore
138/// container.register_scoped({name}::new);
139/// ```
140pub struct {name};
141
142impl {name} {{
143    pub fn new() -> Self {{
144        Self
145    }}
146}}
147
148impl Default for {name} {{
149    fn default() -> Self {{
150        Self::new()
151    }}
152}}
153"#
154    );
155
156    fs::write(&file, &template).with_context(|| format!("Failed to write {file:?}"))?;
157    register_in_mod(dir, &snake)?;
158
159    println!("✓  src/services/{snake}.rs");
160    println!("   Register: container.register(services::{snake}::{name}::new())");
161    Ok(())
162}
163
164// ── `craken make module <Name>` ──────────────────────────────────────────────
165
166/// Scaffold `src/modules/<snake_name>/` with `mod.rs`, `controller.rs`,
167/// and `service.rs`, and register the module in `src/modules/mod.rs`.
168pub fn make_module(name: &str) -> Result<()> {
169    let snake = pascal_to_snake(name);
170    let module_dir = PathBuf::from(format!("src/modules/{snake}"));
171
172    if module_dir.exists() {
173        anyhow::bail!("Module already exists: {module_dir:?}");
174    }
175
176    fs::create_dir_all(&module_dir)?;
177
178    // mod.rs
179    let mod_template = format!(
180        r#"pub mod controller;
181pub mod service;
182
183pub use controller::{name}Controller;
184pub use service::{name}Service;
185"#
186    );
187    fs::write(module_dir.join("mod.rs"), &mod_template)?;
188
189    // controller.rs
190    let controller_template = format!(
191        r#"use axum::Json;
192use craken_http::{{CrakenError, Inject}};
193use craken_macros::{{controller, get, post}};
194use super::service::{name}Service;
195
196pub struct {name}Controller;
197
198#[controller]
199impl {name}Controller {{
200    #[get("/{snake}")]
201    pub async fn index(
202        _svc: Inject<{name}Service>,
203    ) -> Result<Json<Vec<serde_json::Value>>, CrakenError> {{
204        Ok(Json(vec![]))
205    }}
206}}
207"#
208    );
209    fs::write(module_dir.join("controller.rs"), &controller_template)?;
210
211    // service.rs
212    let service_template = format!(
213        r#"pub struct {name}Service;
214
215impl {name}Service {{
216    pub fn new() -> Self {{
217        Self
218    }}
219}}
220
221impl Default for {name}Service {{
222    fn default() -> Self {{
223        Self::new()
224    }}
225}}
226"#
227    );
228    fs::write(module_dir.join("service.rs"), &service_template)?;
229
230    // Register in src/modules/mod.rs
231    register_in_mod(Path::new("src/modules"), &snake)?;
232
233    println!("✓  src/modules/{snake}/mod.rs");
234    println!("   src/modules/{snake}/controller.rs");
235    println!("   src/modules/{snake}/service.rs");
236    println!("   Registered in src/modules/mod.rs");
237    Ok(())
238}
239
240// ── `craken make migration <name>` ──────────────────────────────────────────
241
242/// Scaffold `migrations/<timestamp>_<snake_name>.rs`.
243pub fn make_migration(name: &str) -> Result<()> {
244    let snake = pascal_to_snake(name);
245    let dir = Path::new("src/migrations");
246    fs::create_dir_all(dir)?;
247
248    let timestamp = chrono::Local::now().format("%Y%m%d%H%M%S");
249    let version = format!("{}_{}", timestamp, snake);
250    let file = dir.join(format!("{}.rs", version));
251
252    if file.exists() {
253        anyhow::bail!("Migration already exists: {file:?}");
254    }
255
256    let template = format!(
257        r#"use craken_database::{{async_trait::async_trait, Database, migration::Migration}};
258
259pub struct {name};
260
261#[async_trait]
262impl Migration for {name} {{
263    fn name(&self) -> &'static str {{
264        "{version}"
265    }}
266
267    async fn up(&self, db: &Database) -> anyhow::Result<()> {{
268        // Write your migration UP logic here
269        // sqlx::query("CREATE TABLE IF NOT EXISTS ...").execute(db.pool()).await?;
270        Ok(())
271    }}
272
273    async fn down(&self, db: &Database) -> anyhow::Result<()> {{
274        // Write your migration DOWN logic here
275        // sqlx::query("DROP TABLE IF EXISTS ...").execute(db.pool()).await?;
276        Ok(())
277    }}
278}}
279"#
280    );
281
282    fs::write(&file, &template).with_context(|| format!("Failed to write {file:?}"))?;
283    register_in_mod(dir, &version)?;
284
285    println!("✓  src/migrations/{}.rs", version);
286    Ok(())
287}
288
289// ── `craken new <name>` ───────────────────────────────────────────────────────
290
291/// Scaffold a new project structure in `./<name>`.
292pub fn make_app(name: &str) -> Result<()> {
293    let root = Path::new(name);
294    if root.exists() {
295        anyhow::bail!("Directory already exists: {root:?}");
296    }
297
298    fs::create_dir_all(root.join("src/controllers"))?;
299    fs::create_dir_all(root.join("src/services"))?;
300    fs::create_dir_all(root.join("src/migrations"))?;
301    fs::create_dir_all(root.join("config"))?;
302
303    // Cargo.toml
304    let cargo_toml = format!(
305        r#"[package]
306name = "{name}"
307version = "0.1.0"
308edition = "2021"
309
310[dependencies]
311craken-core = {{ git = "https://github.com/shayyz-code/craken.git" }}
312craken-http = {{ git = "https://github.com/shayyz-code/craken.git" }}
313craken-container = {{ git = "https://github.com/shayyz-code/craken.git" }}
314craken-database = {{ git = "https://github.com/shayyz-code/craken.git" }}
315craken-macros = {{ git = "https://github.com/shayyz-code/craken.git" }}
316craken-logging = {{ git = "https://github.com/shayyz-code/craken.git" }}
317tokio = {{ version = "1.0", features = ["full"] }}
318axum = "0.7"
319serde = {{ version = "1.0", features = ["derive"] }}
320anyhow = "1.0"
321"#
322    );
323    fs::write(root.join("Cargo.toml"), cargo_toml)?;
324
325    // src/main.rs
326    let main_rs = r#"use std::sync::Arc;
327use craken_core::{App, ServiceProvider};
328use craken_container::Container;
329use craken_http::{HttpServer, LoggingMiddleware};
330use craken_database::{Database, migration::MigrationRunner};
331use craken_macros::get;
332
333// ── App Modules ──────────────────────────────────────────────────────────────
334
335mod migrations;
336mod controllers;
337mod services;
338
339// ── Simple Health Route ──────────────────────────────────────────────────────
340
341#[get("/health")]
342pub async fn health() -> &'static str {
343    "OK"
344}
345
346// ── Service Registration ─────────────────────────────────────────────────────
347
348pub struct AppServiceProvider {
349    db: Arc<Database>,
350}
351
352impl ServiceProvider for AppServiceProvider {
353    fn register(&self, c: &mut Container) {
354        c.register_arc(self.db.clone());
355        // Register your application services here
356    }
357}
358
359// ── Application Entry Point ──────────────────────────────────────────────────
360
361#[tokio::main]
362async fn main() -> anyhow::Result<()> {
363    craken_logging::Logging::init();
364
365    // Simple argument handling without manual clap in main.rs
366    let args: Vec<String> = std::env::args().collect();
367    let command = args.get(1).map(|s| s.as_str()).unwrap_or("serve");
368
369    let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
370    let db = Arc::new(Database::connect(&db_url).await?);
371
372    match command {
373        "serve" => {
374            let mut app = App::new();
375            app.register_services(&AppServiceProvider { db: db.clone() });
376            app.boot().await?;
377
378            let addr = args.get(2).map(|s| s.as_str()).unwrap_or("127.0.0.1:8080");
379            println!("🦀 Craken application starting on http://{}", addr);
380
381            HttpServer::new()
382                .with_middleware(LoggingMiddleware)
383                .configure_routes(&HealthRoute)
384                .run(app.into_container(), addr)
385                .await?;
386        }
387        "migrate" => {
388            let mut runner = MigrationRunner::new();
389            // TODO: Register migrations here
390            // runner.add(Box::new(migrations::m20231012_create_users::CreateUsers));
391            runner.run_pending(&db).await?;
392            println!("✓  Migrations complete");
393        }
394        "rollback" => {
395            let mut runner = MigrationRunner::new();
396            // TODO: Register migrations here
397            runner.rollback_last(&db).await?;
398            println!("✓  Rollback complete");
399        }
400        _ => {
401            println!("Unknown command. Use: serve, migrate, rollback");
402        }
403    }
404    Ok(())
405}
406"#;
407    fs::write(root.join("src/main.rs"), main_rs)?;
408
409    // .env
410    fs::write(
411        root.join(".env"),
412        "DATABASE_URL=postgres://postgres:password@localhost/my_app\n",
413    )?;
414
415    // src/controllers/mod.rs, src/services/mod.rs, src/migrations/mod.rs
416    fs::write(root.join("src/controllers/mod.rs"), "")?;
417    fs::write(root.join("src/services/mod.rs"), "")?;
418    fs::write(root.join("src/migrations/mod.rs"), "")?;
419
420    println!("✓  Created new Craken project in: {}", name);
421    println!("   Next steps:");
422    println!("     cd {}", name);
423    println!("     cargo run -- serve");
424
425    Ok(())
426}
427
428/// Scaffold a brand-new Craken project with the standard directory layout.
429///
430/// ```text
431/// <name>/
432///   Cargo.toml
433///   src/
434///     main.rs
435///     controllers/mod.rs
436///     services/mod.rs
437///     modules/mod.rs
438///     middleware/mod.rs
439///     models/mod.rs
440/// ```
441pub fn scaffold_project(name: &str) -> Result<()> {
442    let root = Path::new(name);
443    if root.exists() {
444        anyhow::bail!("Directory '{name}' already exists");
445    }
446
447    // Directory tree
448    for sub in &[
449        "src/controllers",
450        "src/services",
451        "src/modules",
452        "src/middleware",
453        "src/models",
454    ] {
455        fs::create_dir_all(root.join(sub))?;
456    }
457
458    // Stub mod.rs files
459    let mod_stub = "// Auto-generated by Craken. Add `pub mod` declarations here.\n";
460    for sub in &["controllers", "services", "modules", "middleware", "models"] {
461        fs::write(root.join(format!("src/{sub}/mod.rs")), mod_stub)?;
462    }
463
464    // Cargo.toml
465    let cargo = format!(
466        r#"[package]
467name    = "{name}"
468version = "0.1.0"
469edition = "2021"
470
471[dependencies]
472craken-core    = "0.1"
473craken-http    = "0.1"
474craken-macros  = "0.1"
475craken-logging = "0.1"
476tokio          = {{ version = "1.0", features = ["full"] }}
477axum           = "0.7"
478serde          = {{ version = "1.0", features = ["derive"] }}
479serde_json     = "1.0"
480anyhow         = "1.0"
481tracing        = "0.1"
482"#
483    );
484    fs::write(root.join("Cargo.toml"), cargo)?;
485
486    // src/main.rs
487    let main_rs = r#"mod controllers;
488mod services;
489mod modules;
490mod middleware;
491mod models;
492
493use craken_core::App;
494use craken_http::{HttpServer, LoggingMiddleware};
495
496#[tokio::main]
497async fn main() -> anyhow::Result<()> {
498    craken_logging::Logging::init();
499
500    let mut app = App::new();
501    app.boot().await?;
502    // app.register_services(&services::AppServiceProvider);
503
504    let container = app.into_container();
505
506    HttpServer::new()
507        .with_middleware(LoggingMiddleware)
508        // .configure_routes(&controllers::YourController)
509        .run(container, "127.0.0.1:8080")
510        .await?;
511
512    Ok(())
513}
514"#;
515    fs::write(root.join("src/main.rs"), main_rs)?;
516
517    println!("✓  Created project '{name}'");
518    println!("   cd {name} && cargo run -- serve");
519    Ok(())
520}