Skip to main content

kegani_cli/commands/
gen.rs

1//! `keg gen` command — Code generation toolchain
2//!
3//! Subcommands:
4//!   api <name>      — Generate full CRUD stack (model + repository + logic + service + controller + routes)
5//!   model <name>    — Generate entity struct
6//!   service <name>  — Generate service layer
7//!   repository <name> — Generate repository (sqlx)
8//!   controller <name> — Generate controller + routes
9//!   middleware <name> — Generate named middleware
10//!   migration <name>  — Generate SQL migration
11
12use anyhow::{Context, Result};
13use clap::ValueEnum;
14use console::{style, Emoji};
15use std::fs;
16use std::path::Path;
17
18/// Placeholders used in templates
19const PROJECT_NAME_PLACEHOLDER: &str = "{{PROJECT_NAME}}";
20const PROJECT_NAME_SNAKE_PLACEHOLDER: &str = "{{PROJECT_NAME_SNAKE}}";
21const RESOURCE_NAME_PLACEHOLDER: &str = "{{RESOURCE_NAME}}";
22const RESOURCE_NAME_PASCAL_PLACEHOLDER: &str = "{{RESOURCE_NAME_PASCAL}}";
23
24/// What to generate
25#[derive(Debug, Clone, Copy, ValueEnum)]
26pub enum GenKind {
27    /// Full CRUD stack (model + repository + logic + service + controller + routes)
28    Api,
29    /// Entity/model only
30    Model,
31    /// Service layer
32    Service,
33    /// Repository (sqlx)
34    Repository,
35    /// Controller + routes
36    Controller,
37    /// Middleware
38    Middleware,
39    /// SQL migration
40    Migration,
41}
42
43/// Generate code
44pub fn gen(kind: GenKind, name: &str, list: bool) -> Result<()> {
45    if list {
46        return list_gen_types();
47    }
48
49    println!();
50    println!(
51        "{} {} {}",
52        Emoji("🔧", ""),
53        style("Generating").bold(),
54        style(format!("{:?}", kind).to_lowercase()).cyan()
55    );
56    println!();
57
58    match kind {
59        GenKind::Api => gen_api(name)?,
60        GenKind::Model => gen_model(name)?,
61        GenKind::Service => gen_service(name)?,
62        GenKind::Repository => gen_repository(name)?,
63        GenKind::Controller => gen_controller(name)?,
64        GenKind::Middleware => gen_middleware(name)?,
65        GenKind::Migration => gen_migration(name)?,
66    }
67
68    println!();
69    println!(
70        "{} {} generated successfully!",
71        Emoji("✨", ""),
72        style(name).cyan().bold()
73    );
74    println!();
75
76    Ok(())
77}
78
79/// List available generation types
80fn list_gen_types() -> Result<()> {
81    println!();
82    println!("{} {}", Emoji("📋", ""), style("Available Code Generation Types").bold());
83    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
84    println!();
85
86    let types = vec![
87        ("api", "Full CRUD stack", "model + repository + logic + service + controller + routes"),
88        ("model", "Entity/Model", "Generate an entity struct with derive macros"),
89        ("service", "Service layer", "Business logic service with traits"),
90        ("repository", "Repository", "Data access layer with sqlx"),
91        ("controller", "Controller + Routes", "HTTP handler and route definitions"),
92        ("middleware", "Middleware", "Custom middleware (auth, ratelimit, etc.)"),
93        ("migration", "SQL Migration", "Database migration file"),
94    ];
95
96    for (name, title, desc) in types {
97        println!("  {} {}", style(name).cyan().bold(), style(title).white());
98        println!("     {}", style(desc).dim());
99        println!();
100    }
101
102    println!("  {} Usage: keg gen <type> <name>", style("ℹ").dim());
103    println!("  {} Example: keg gen api article", style("ℹ").dim());
104
105    Ok(())
106}
107
108// ── Full CRUD stack ──────────────────────────────────────────────────────────
109
110fn gen_api(name: &str) -> Result<()> {
111    let pascal = to_pascal_case(name);
112    let snake = to_snake_case(name);
113
114    let replacements = &[
115        (PROJECT_NAME_PLACEHOLDER, "TODO_project_name"),
116        (PROJECT_NAME_SNAKE_PLACEHOLDER, "TODO_project_snake"),
117        (RESOURCE_NAME_PLACEHOLDER, &snake),
118        (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
119    ];
120
121    // internal/model/entity/<name>.rs
122    let entity_dir = Path::new("internal/model/entity");
123    fs::create_dir_all(entity_dir).context("Failed to create internal/model/entity/")?;
124    write_file(
125        &entity_dir.join(format!("{}.rs", snake)),
126        &render_template(include_str!("../templates/internal_model_entity_RESOURCE_rs.txt"), replacements),
127    )?;
128    println!("  {} internal/model/entity/{}.rs", style("✓").green(), snake);
129
130    // internal/repository/<name>_repo.rs
131    let repo_dir = Path::new("internal/repository");
132    fs::create_dir_all(repo_dir).context("Failed to create internal/repository/")?;
133    write_file(
134        &repo_dir.join(format!("{}_repo.rs", snake)),
135        &render_template(include_str!("../templates/internal_repository_RESOURCE_rs.txt"), replacements),
136    )?;
137    println!("  {} internal/repository/{}_repo.rs", style("✓").green(), snake);
138
139    // internal/logic/<name>_logic.rs
140    let logic_dir = Path::new("internal/logic");
141    fs::create_dir_all(logic_dir).context("Failed to create internal/logic/")?;
142    write_file(
143        &logic_dir.join(format!("{}_logic.rs", snake)),
144        &render_template(include_str!("../templates/internal_logic_RESOURCE_rs.txt"), replacements),
145    )?;
146    println!("  {} internal/logic/{}_logic.rs", style("✓").green(), snake);
147
148    // internal/service/<name>_service.rs
149    let service_dir = Path::new("internal/service");
150    fs::create_dir_all(service_dir).context("Failed to create internal/service/")?;
151    write_file(
152        &service_dir.join(format!("{}_service.rs", snake)),
153        &render_template(include_str!("../templates/internal_service_RESOURCE_rs.txt"), replacements),
154    )?;
155    println!("  {} internal/service/{}_service.rs", style("✓").green(), snake);
156
157    // src/controller/api/<name>.rs
158    let controller_dir = Path::new("src/controller/api");
159    fs::create_dir_all(controller_dir).context("Failed to create src/controller/api/")?;
160    write_file(
161        &controller_dir.join(format!("{}.rs", snake)),
162        &render_template(include_str!("../templates/src_controller_api_RESOURCE_rs.txt"), replacements),
163    )?;
164    println!("  {} src/controller/api/{}.rs", style("✓").green(), snake);
165
166    // src/routes/api/v1/<name>.rs
167    let routes_dir = Path::new("src/routes/api/v1");
168    fs::create_dir_all(routes_dir).context("Failed to create src/routes/api/v1/")?;
169    write_file(
170        &routes_dir.join(format!("{}.rs", snake)),
171        &render_template(include_str!("../templates/src_routes_api_v1_RESOURCE_rs.txt"), replacements),
172    )?;
173    println!("  {} src/routes/api/v1/{}.rs", style("✓").green(), snake);
174
175    // Update module files
176    update_mod_file(Path::new("internal/model/entity/mod.rs"), &format!("pub mod {};", snake));
177    update_mod_file(Path::new("internal/repository/mod.rs"), &format!("pub mod {};", snake));
178    update_mod_file(Path::new("internal/logic/mod.rs"), &format!("pub mod {};", snake));
179    update_mod_file(Path::new("internal/service/mod.rs"), &format!("pub mod {};", snake));
180    update_mod_file(Path::new("src/controller/api/mod.rs"), &format!("pub mod {};", snake));
181
182    // Update routes api v1 mod
183    update_routes_v1_mod(&snake);
184
185    Ok(())
186}
187
188// ── Model ─────────────────────────────────────────────────────────────────────
189
190fn gen_model(name: &str) -> Result<()> {
191    let pascal = to_pascal_case(name);
192    let snake = to_snake_case(name);
193
194    let entity_dir = Path::new("internal/model/entity");
195    fs::create_dir_all(entity_dir).context("Failed to create internal/model/entity/")?;
196    write_file(
197        &entity_dir.join(format!("{}.rs", snake)),
198        &render_template(include_str!("../templates/internal_model_entity_RESOURCE_rs.txt"), &[
199            (RESOURCE_NAME_PLACEHOLDER, &snake),
200            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
201        ]),
202    )?;
203    println!("  {} internal/model/entity/{}.rs", style("✓").green(), snake);
204
205    update_mod_file(Path::new("internal/model/entity/mod.rs"), &format!("pub mod {};", snake));
206
207    Ok(())
208}
209
210// ── Service ───────────────────────────────────────────────────────────────────
211
212fn gen_service(name: &str) -> Result<()> {
213    let pascal = to_pascal_case(name);
214    let snake = to_snake_case(name);
215
216    let service_dir = Path::new("internal/service");
217    fs::create_dir_all(service_dir).context("Failed to create internal/service/")?;
218    write_file(
219        &service_dir.join(format!("{}_service.rs", snake)),
220        &render_template(include_str!("../templates/internal_service_RESOURCE_rs.txt"), &[
221            (PROJECT_NAME_PLACEHOLDER, "TODO_project_name"),
222            (PROJECT_NAME_SNAKE_PLACEHOLDER, "TODO_project_snake"),
223            (RESOURCE_NAME_PLACEHOLDER, &snake),
224            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
225        ]),
226    )?;
227    println!("  {} internal/service/{}_service.rs", style("✓").green(), snake);
228
229    update_mod_file(Path::new("internal/service/mod.rs"), &format!("pub mod {};", snake));
230
231    Ok(())
232}
233
234// ── Repository ───────────────────────────────────────────────────────────────
235
236fn gen_repository(name: &str) -> Result<()> {
237    let pascal = to_pascal_case(name);
238    let snake = to_snake_case(name);
239
240    let repo_dir = Path::new("internal/repository");
241    fs::create_dir_all(repo_dir).context("Failed to create internal/repository/")?;
242    write_file(
243        &repo_dir.join(format!("{}_repo.rs", snake)),
244        &render_template(include_str!("../templates/internal_repository_RESOURCE_rs.txt"), &[
245            (PROJECT_NAME_PLACEHOLDER, "TODO_project_name"),
246            (PROJECT_NAME_SNAKE_PLACEHOLDER, "TODO_project_snake"),
247            (RESOURCE_NAME_PLACEHOLDER, &snake),
248            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
249        ]),
250    )?;
251    println!("  {} internal/repository/{}_repo.rs", style("✓").green(), snake);
252
253    update_mod_file(Path::new("internal/repository/mod.rs"), &format!("pub mod {};", snake));
254
255    Ok(())
256}
257
258// ── Controller ───────────────────────────────────────────────────────────────
259
260fn gen_controller(name: &str) -> Result<()> {
261    let pascal = to_pascal_case(name);
262    let snake = to_snake_case(name);
263
264    let controller_dir = Path::new("src/controller/api");
265    fs::create_dir_all(controller_dir).context("Failed to create src/controller/api/")?;
266    write_file(
267        &controller_dir.join(format!("{}.rs", snake)),
268        &render_template(include_str!("../templates/src_controller_api_RESOURCE_rs.txt"), &[
269            (PROJECT_NAME_PLACEHOLDER, "TODO_project_name"),
270            (PROJECT_NAME_SNAKE_PLACEHOLDER, "TODO_project_snake"),
271            (RESOURCE_NAME_PLACEHOLDER, &snake),
272            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
273        ]),
274    )?;
275    println!("  {} src/controller/api/{}.rs", style("✓").green(), snake);
276
277    update_mod_file(Path::new("src/controller/api/mod.rs"), &format!("pub mod {};", snake));
278
279    // Routes
280    let routes_dir = Path::new("src/routes/api/v1");
281    fs::create_dir_all(routes_dir).context("Failed to create src/routes/api/v1/")?;
282    write_file(
283        &routes_dir.join(format!("{}.rs", snake)),
284        &render_template(include_str!("../templates/src_routes_api_v1_RESOURCE_rs.txt"), &[
285            (RESOURCE_NAME_PLACEHOLDER, &snake),
286        ]),
287    )?;
288    println!("  {} src/routes/api/v1/{}.rs", style("✓").green(), snake);
289
290    update_routes_v1_mod(&snake);
291
292    Ok(())
293}
294
295// ── Middleware ───────────────────────────────────────────────────────────────
296
297fn gen_middleware(name: &str) -> Result<()> {
298    let pascal = to_pascal_case(name);
299    let snake = to_snake_case(name);
300
301    let middleware_dir = Path::new("src/middleware");
302    fs::create_dir_all(middleware_dir).context("Failed to create src/middleware/")?;
303    write_file(
304        &middleware_dir.join(format!("{}.rs", snake)),
305        &render_template(include_str!("../templates/src_middleware_auth_rs.txt"), &[
306            (RESOURCE_NAME_PLACEHOLDER, &snake),
307            (RESOURCE_NAME_PASCAL_PLACEHOLDER, &pascal),
308        ]).replace("JwtAuth", &pascal).replace("JwtAuthMiddleware", &format!("{}Middleware", pascal)).as_str(),
309    )?;
310    println!("  {} src/middleware/{}.rs", style("✓").green(), snake);
311
312    update_mod_file(Path::new("src/middleware/mod.rs"), &format!("pub mod {};", snake));
313
314    Ok(())
315}
316
317// ── Migration ─────────────────────────────────────────────────────────────────
318
319fn gen_migration(name: &str) -> Result<()> {
320    let snake = to_snake_case(name);
321    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
322
323    let migrations_dir = Path::new("migrations");
324    fs::create_dir_all(migrations_dir).context("Failed to create migrations/")?;
325
326    let mut content = include_str!("../templates/migrations_001_init_sql.txt").to_string();
327    content = content.replace("{{RESOURCE_NAME}}", &snake);
328
329    let file_name = format!("{}_{}.sql", timestamp, snake);
330    write_file(&migrations_dir.join(&file_name), &content)?;
331    println!("  {} migrations/{}", style("✓").green(), file_name);
332
333    Ok(())
334}
335
336// ── Helpers ────────────────────────────────────────────────────────────────────
337
338fn render_template(content: &str, replacements: &[(&str, &str)]) -> String {
339    let mut result = content.to_string();
340    for (placeholder, value) in replacements {
341        result = result.replace(placeholder, value);
342    }
343    result
344}
345
346fn write_file(path: &Path, content: &str) -> Result<()> {
347    fs::write(path, content).context(format!("Failed to write file: {:?}", path))
348}
349
350fn update_mod_file(path: &Path, new_line: &str) {
351    let content = if path.exists() {
352        fs::read_to_string(path).unwrap_or_default()
353    } else {
354        String::new()
355    };
356
357    if !content.contains(new_line.trim()) {
358        let new_content = if content.trim().is_empty() {
359            new_line.to_string()
360        } else {
361            format!("{}\n{}", content.trim(), new_line)
362        };
363        let _ = fs::write(path, new_content);
364    }
365}
366
367fn update_routes_v1_mod(name: &str) {
368    let path = Path::new("src/routes/api/v1/mod.rs");
369    let content = if path.exists() {
370        fs::read_to_string(path).unwrap_or_default()
371    } else {
372        r#"//! API v1 routes
373pub mod __placeholder__;
374"#.to_string()
375    };
376
377    if !content.contains(&format!("pub mod {};", name)) {
378        let new_content = content.replace("pub mod __placeholder__;", &format!("pub mod {};\npub mod __placeholder__;", name));
379        let _ = fs::write(path, new_content);
380    }
381}
382
383fn to_snake_case(s: &str) -> String {
384    let mut result = String::new();
385    for (i, c) in s.chars().enumerate() {
386        if c.is_uppercase() && i > 0 {
387            result.push('_');
388        }
389        result.push(c.to_ascii_lowercase());
390    }
391    result.replace('-', "_").replace(' ', "_")
392}
393
394fn to_pascal_case(s: &str) -> String {
395    let mut result = String::new();
396    let mut capitalize_next = true;
397    for c in s.chars() {
398        if c == '-' || c == '_' || c == ' ' {
399            capitalize_next = true;
400        } else if capitalize_next {
401            result.extend(c.to_uppercase());
402            capitalize_next = false;
403        } else {
404            result.push(c);
405        }
406    }
407    result
408}