Skip to main content

kegani_cli/commands/
init.rs

1//! `keg init` command — Create a complete Kegani project
2//!
3//! Generates a full layered architecture project with:
4//!   - manifest/config/     (configuration system)
5//!   - internal/           (model → dto → repository → logic → service)
6//!   - src/                (controller → routes → middleware)
7//!   - resource/           (static assets, OpenAPI spec)
8//!   - migrations/         (SQL migrations)
9//!   - Dockerfile + docker-compose.yml
10
11use anyhow::{Context, Result};
12use console::{style, Emoji};
13use std::fs;
14use std::io::{self, Write};
15use std::path::Path;
16
17/// Placeholder values for templates
18const AUTHOR_PLACEHOLDER: &str = "{{AUTHOR}}";
19const PROJECT_NAME_PLACEHOLDER: &str = "{{PROJECT_NAME}}";
20const PROJECT_NAME_SNAKE_PLACEHOLDER: &str = "{{PROJECT_NAME_SNAKE}}";
21const PROJECT_NAME_PASCAL_PLACEHOLDER: &str = "{{PROJECT_NAME_PASCAL}}";
22const RESOURCE_NAME_PLACEHOLDER: &str = "{{RESOURCE_NAME}}";
23const RESOURCE_NAME_PASCAL_PLACEHOLDER: &str = "{{RESOURCE_NAME_PASCAL}}";
24
25/// List available project templates
26pub fn list_templates(verbose: bool) -> Result<()> {
27    println!();
28    println!("{} {}", Emoji("📋", ""), style("Available Project Templates").bold());
29    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
30    println!();
31
32    let templates = vec![
33        ("api", "API Project", "Full-featured REST API with all layers"),
34        ("minimal", "Minimal", "Minimal project with just the basics"),
35        ("web", "Web Application", "Full-stack web application template"),
36        ("service", "Microservice", "Lightweight microservice template"),
37    ];
38
39    for (name, title, desc) in templates {
40        println!("  {} {}", style(name).cyan().bold(), style(title).white());
41        if verbose {
42            println!("     {}", style(desc).dim());
43        }
44        println!();
45    }
46
47    if !verbose {
48        println!("  {} Run with --verbose for detailed descriptions", style("ℹ").dim());
49    }
50
51    Ok(())
52}
53
54/// Prompt for project name (interactive mode)
55pub fn prompt_project_name() -> String {
56    print!("{} {}", style("?").cyan(), style("Project name: ").bold());
57    io::stdout().flush().ok();
58
59    let mut name = String::new();
60    if io::stdin().read_line(&mut name).is_ok() {
61        name.trim().to_string()
62    } else {
63        String::new()
64    }
65}
66
67/// Prompt for template selection (interactive mode)
68fn prompt_template() -> String {
69    println!();
70    println!("{}", style("Available templates:").bold());
71    println!();
72    println!("  1) api      - Full-featured REST API (default)");
73    println!("  2) minimal  - Minimal project with basics");
74    println!("  3) web      - Full-stack web application");
75    println!("  4) service  - Lightweight microservice");
76    println!();
77
78    print!("{} {}", style("?").cyan(), style("Select template [1-4] (or press Enter for api): ").bold());
79    io::stdout().flush().ok();
80
81    let mut input = String::new();
82    if io::stdin().read_line(&mut input).is_ok() {
83        match input.trim() {
84            "2" => "minimal".to_string(),
85            "3" => "web".to_string(),
86            "4" => "service".to_string(),
87            _ => "api".to_string(),
88        }
89    } else {
90        "api".to_string()
91    }
92}
93
94/// Create a complete Kegani project
95pub fn init(name: &str, template: &str, interactive: bool) -> Result<()> {
96    // In interactive mode, prompt for missing values
97    let (name, template) = if interactive {
98        let name = if name.is_empty() {
99            prompt_project_name()
100        } else {
101            name.to_string()
102        };
103
104        if name.is_empty() {
105            anyhow::bail!("Project name cannot be empty");
106        }
107
108        let template = if template == "api" {
109            prompt_template()
110        } else {
111            template.to_string()
112        };
113
114        (name, template)
115    } else {
116        (name.to_string(), template.to_string())
117    };
118
119    let project_path = Path::new(&name);
120
121    // Validate
122    if project_path.exists() && project_path.is_dir() {
123        let entries = fs::read_dir(project_path)?;
124        if entries.count() > 0 {
125            anyhow::bail!(
126                "Directory '{}' is not empty. Please specify an empty directory or a new name.",
127                name
128            );
129        }
130    }
131
132    if !name.matches(['/', '\\']).take(1).next().is_none() {
133        // name contains a path separator, check if parent exists
134        if let Some(parent) = project_path.parent() {
135            if !parent.exists() {
136                anyhow::bail!("Parent directory does not exist: {}", parent.display());
137            }
138        }
139    }
140
141    println!();
142    println!("{} {}", Emoji("⚡", ""), style("Initializing Kegani project").bold());
143    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
144    println!("  {} {}", style("Project:").dim(), style(&name).cyan().bold());
145    println!("  {} {}", style("Template:").dim(), style(&template).cyan().bold());
146    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
147    println!();
148
149    // Create project directory
150    if !project_path.exists() {
151        fs::create_dir_all(project_path).context("Failed to create project directory")?;
152    }
153    println!("{} {}", Emoji("📁", ""), style("Created project directory").green());
154
155    // Generate project structure
156    generate_project_structure(project_path, &name)?;
157
158    println!();
159    println!(
160        "{} {} project '{}' initialized!",
161        Emoji("🎉", ""),
162        style("✓").green(),
163        style(&name).cyan().bold()
164    );
165    println!();
166    println!("{}", style("Next steps:").bold());
167    println!(
168        "  {} {} {}",
169        style("1.").dim(),
170        style("cd"),
171        style(&name).cyan()
172    );
173    println!("  {} {}", style("2.").dim(), style("cp .env.example .env"));
174    println!(
175        "  {} {} {}",
176        style("3.").dim(),
177        style("cargo run"),
178        style("(starts on http://127.0.0.1:8080)").dim()
179    );
180    println!("  {} {} {}", style("4.").dim(), style("keg gen api"), style("to generate your first resource").dim());
181    println!();
182
183    Ok(())
184}
185
186fn generate_project_structure(project_path: &Path, project_name: &str) -> Result<()> {
187    let snake_name = to_snake_case(project_name);
188    let pascal_name = to_pascal_case(project_name);
189
190    let replacements = &[
191        (PROJECT_NAME_PLACEHOLDER, project_name),
192        (PROJECT_NAME_SNAKE_PLACEHOLDER, &snake_name),
193        (PROJECT_NAME_PASCAL_PLACEHOLDER, &pascal_name),
194        (AUTHOR_PLACEHOLDER, "Your Name <you@example.com>"),
195    ];
196
197    // Detect if current directory is inside the kegani workspace by checking
198    // the parent chain for kegani/Cargo.toml (the workspace root marker).
199    let workspace_root = find_kegani_workspace(&std::env::current_dir()?);
200    if workspace_root.is_some() {
201        println!("  {} {}", style("ℹ").dim(), style("Detected kegani workspace — using local path dependency").dim());
202    }
203
204    println!("{} Creating project structure", style("⚙️").cyan());
205
206    // ── src/ ──────────────────────────────────────────────────────────────────
207
208    let src_dir = project_path.join("src");
209    fs::create_dir_all(&src_dir).context("Failed to create src/")?;
210
211    write_file(&src_dir.join("main.rs"), &render_template(include_str!("../templates/src_main_rs.txt"), replacements))?;
212    println!("  {} {}", style("✓").green(), style("src/main.rs").dim());
213
214    write_file(&src_dir.join("lib.rs"), &render_template(include_str!("../templates/src_lib_rs.txt"), replacements))?;
215    println!("  {} {}", style("✓").green(), style("src/lib.rs").dim());
216
217    write_file(&src_dir.join("error.rs"), &render_template(include_str!("../templates/src_error_rs.txt"), replacements))?;
218    println!("  {} {}", style("✓").green(), style("src/error.rs").dim());
219
220    // src/routes/
221    let routes_dir = src_dir.join("routes");
222    fs::create_dir_all(&routes_dir).context("Failed to create src/routes/")?;
223    write_file(&routes_dir.join("mod.rs"), &render_template(include_str!("../templates/src_routes_mod_rs.txt"), replacements))?;
224    println!("  {} {}", style("✓").green(), style("src/routes/mod.rs").dim());
225
226    let routes_api_dir = routes_dir.join("api");
227    fs::create_dir_all(&routes_api_dir).context("Failed to create src/routes/api/")?;
228    write_file(&routes_api_dir.join("mod.rs"), &render_template(include_str!("../templates/src_routes_api_mod_rs.txt"), replacements))?;
229    println!("  {} {}", style("✓").green(), style("src/routes/api/mod.rs").dim());
230
231    // v1 directory created empty; populated by `keg gen api <name>`
232    let routes_api_v1_dir = routes_api_dir.join("v1");
233    fs::create_dir_all(&routes_api_v1_dir).context("Failed to create src/routes/api/v1/")?;
234    println!("  {} {}", style("✓").green(), style("src/routes/api/v1/ (empty, populated by keg gen api)").dim());
235
236    // Health routes
237    write_file(&routes_dir.join("health.rs"), &render_template(include_str!("../templates/src_routes_health_rs.txt"), replacements))?;
238    println!("  {} {}", style("✓").green(), style("src/routes/health.rs").dim());
239
240    // src/controller/
241    let controller_dir = src_dir.join("controller");
242    fs::create_dir_all(&controller_dir).context("Failed to create src/controller/")?;
243    write_file(&controller_dir.join("mod.rs"), &render_template(include_str!("../templates/src_controller_mod_rs.txt"), &[
244        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
245    ]))?;
246    println!("  {} {}", style("✓").green(), style("src/controller/mod.rs").dim());
247
248    let controller_api_dir = controller_dir.join("api");
249    fs::create_dir_all(&controller_api_dir).context("Failed to create src/controller/api/")?;
250    write_file(&controller_api_dir.join("mod.rs"), &render_template(include_str!("../templates/src_controller_api_mod_rs.txt"), &[
251        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
252        (RESOURCE_NAME_PASCAL_PLACEHOLDER, "{{RESOURCE_NAME_PASCAL}}"),
253    ]))?;
254    println!("  {} {}", style("✓").green(), style("src/controller/api/mod.rs").dim());
255
256    // src/middleware/
257    let middleware_dir = src_dir.join("middleware");
258    fs::create_dir_all(&middleware_dir).context("Failed to create src/middleware/")?;
259    write_file(&middleware_dir.join("mod.rs"), &render_template(include_str!("../templates/src_middleware_mod_rs.txt"), replacements))?;
260    println!("  {} {}", style("✓").green(), style("src/middleware/mod.rs").dim());
261    write_file(&middleware_dir.join("auth.rs"), &render_template(include_str!("../templates/src_middleware_auth_rs.txt"), replacements))?;
262    println!("  {} {}", style("✓").green(), style("src/middleware/auth.rs").dim());
263
264    // ── internal/ ─────────────────────────────────────────────────────────────
265
266    let internal_dir = project_path.join("internal");
267    fs::create_dir_all(&internal_dir).context("Failed to create internal/")?;
268    write_file(&internal_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_mod_rs.txt"), replacements))?;
269    println!("  {} {}", style("✓").green(), style("internal/mod.rs").dim());
270
271    // internal/model/
272    let model_dir = internal_dir.join("model");
273    fs::create_dir_all(&model_dir).context("Failed to create internal/model/")?;
274    write_file(&model_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_model_mod_rs.txt"), replacements))?;
275    println!("  {} {}", style("✓").green(), style("internal/model/mod.rs").dim());
276
277    let entity_dir = model_dir.join("entity");
278    fs::create_dir_all(&entity_dir).context("Failed to create internal/model/entity/")?;
279    write_file(&entity_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_model_entity_mod_rs.txt"), &[
280        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
281    ]))?;
282    println!("  {} {}", style("✓").green(), style("internal/model/entity/mod.rs").dim());
283
284    // internal/dto/
285    let dto_dir = internal_dir.join("dto");
286    fs::create_dir_all(&dto_dir).context("Failed to create internal/dto/")?;
287    write_file(&dto_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_mod_rs.txt"), replacements))?;
288    println!("  {} {}", style("✓").green(), style("internal/dto/mod.rs").dim());
289
290    let dto_req_dir = dto_dir.join("requests");
291    fs::create_dir_all(&dto_req_dir).context("Failed to create internal/dto/requests/")?;
292    write_file(&dto_req_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_requests_mod_rs.txt"), &[
293        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
294    ]))?;
295
296    let dto_resp_dir = dto_dir.join("responses");
297    fs::create_dir_all(&dto_resp_dir).context("Failed to create internal/dto/responses/")?;
298    write_file(&dto_resp_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_responses_mod_rs.txt"), &[
299        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
300    ]))?;
301
302    // internal/repository/
303    let repo_dir = internal_dir.join("repository");
304    fs::create_dir_all(&repo_dir).context("Failed to create internal/repository/")?;
305    write_file(&repo_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_repository_mod_rs.txt"), &[
306        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
307    ]))?;
308    println!("  {} {}", style("✓").green(), style("internal/repository/mod.rs").dim());
309
310    // internal/logic/
311    let logic_dir = internal_dir.join("logic");
312    fs::create_dir_all(&logic_dir).context("Failed to create internal/logic/")?;
313    write_file(&logic_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_logic_mod_rs.txt"), &[
314        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
315    ]))?;
316    println!("  {} {}", style("✓").green(), style("internal/logic/mod.rs").dim());
317
318    // internal/service/
319    let service_dir = internal_dir.join("service");
320    fs::create_dir_all(&service_dir).context("Failed to create internal/service/")?;
321    write_file(&service_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_service_mod_rs.txt"), &[
322        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
323    ]))?;
324    println!("  {} {}", style("✓").green(), style("internal/service/mod.rs").dim());
325
326    // ── manifest/config/ ──────────────────────────────────────────────────────
327
328    let manifest_dir = project_path.join("manifest").join("config");
329    fs::create_dir_all(&manifest_dir).context("Failed to create manifest/config/")?;
330    write_file(&manifest_dir.join("config.yaml"), &render_template(include_str!("../templates/manifest_config_yaml.txt"), replacements))?;
331    println!("  {} {}", style("✓").green(), style("manifest/config/config.yaml").dim());
332    write_file(&manifest_dir.join("config.prod.yaml"), &render_template(include_str!("../templates/manifest_config_prod_yaml.txt"), replacements))?;
333    write_file(&manifest_dir.join("config.test.yaml"), &render_template(include_str!("../templates/manifest_config_test_yaml.txt"), replacements))?;
334
335    // ── resource/ ────────────────────────────────────────────────────────────
336
337    let resource_dir = project_path.join("resource").join("openapi");
338    fs::create_dir_all(&resource_dir).context("Failed to create resource/openapi/")?;
339    write_file(&resource_dir.join("schema.yaml"), &render_template(include_str!("../templates/resource_openapi_schema_yaml.txt"), replacements))?;
340    println!("  {} {}", style("✓").green(), style("resource/openapi/schema.yaml").dim());
341
342    // ── protocol/ ──────────────────────────────────────────────────────────────
343
344    let protocol_dir = project_path.join("protocol").join("protobuf");
345    fs::create_dir_all(&protocol_dir).context("Failed to create protocol/protobuf/")?;
346    write_file(&protocol_dir.join("mod.rs"), &render_template(include_str!("../templates/protocol_protobuf_mod_rs.txt"), replacements))?;
347
348    // ── migrations/ ──────────────────────────────────────────────────────────
349
350    let migrations_dir = project_path.join("migrations");
351    fs::create_dir_all(&migrations_dir).context("Failed to create migrations/")?;
352    write_file(&migrations_dir.join("001_init.sql"), &render_template(include_str!("../templates/migrations_001_init_sql.txt"), &[
353        (RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
354    ]))?;
355    println!("  {} {}", style("✓").green(), style("migrations/001_init.sql").dim());
356
357    // ── tests/ ─────────────────────────────────────────────────────────────────
358
359    let tests_dir = project_path.join("tests");
360    fs::create_dir_all(&tests_dir).context("Failed to create tests/")?;
361    write_file(&tests_dir.join("api_test.rs"), &render_template(include_str!("../templates/tests_api_test_rs.txt"), replacements))?;
362    write_file(&tests_dir.join("mod.rs"), include_str!("../templates/tests_mod_rs.txt"))?;
363    println!("  {} {}", style("✓").green(), style("tests/api_test.rs").dim());
364
365    // ── Root files ─────────────────────────────────────────────────────────────
366
367    // Write Cargo.toml with workspace detection.
368    // Two variants: local path (inside kegani workspace) vs crates.io (outside)
369    let cargo_toml_path = project_path.join("Cargo.toml");
370    let cargo_content = render_template(
371        include_str!("../templates/Cargo.toml.txt"),
372        &[
373            (PROJECT_NAME_PLACEHOLDER, project_name),
374            (PROJECT_NAME_SNAKE_PLACEHOLDER, &snake_name),
375            (AUTHOR_PLACEHOLDER, "Your Name <you@example.com>"),
376        ],
377    );
378    // When inside the kegani workspace, use a relative path from the generated
379    // project to the workspace root. The generated project is at
380    // <workspace_root>/<project_name>/, so ".." reaches the workspace root.
381    let cargo_content = if find_kegani_workspace(&std::env::current_dir()?).is_some() {
382        cargo_content.replace("{{KEGANI_DEP}}", "kegani = { path = \"..\" }")
383    } else {
384        cargo_content.replace("{{KEGANI_DEP}}", "kegani = \"0.1\"")
385    };
386    write_file(&cargo_toml_path, &cargo_content)?;
387    println!("  {} {}", style("✓").green(), style("Cargo.toml").dim());
388
389    write_file(&project_path.join("config.yaml"), include_str!("../templates/config_yaml.txt"))?;
390    write_file(&project_path.join(".env.example"), &render_template(include_str!("../templates/env_example_txt.txt"), &[
391        (PROJECT_NAME_PLACEHOLDER, project_name),
392    ]))?;
393    write_file(&project_path.join(".env"), "# Copy from .env.example and fill in your values\n")?;
394    println!("  {} {}", style("✓").green(), style(".env.example").dim());
395
396    write_file(&project_path.join(".gitignore"), include_str!("../templates/gitignore_txt.txt"))?;
397    println!("  {} {}", style("✓").green(), style(".gitignore").dim());
398
399    write_file(&project_path.join("README.md"), &render_template(include_str!("../templates/readme_md.txt"), &[
400        (PROJECT_NAME_PLACEHOLDER, project_name),
401    ]))?;
402    println!("  {} {}", style("✓").green(), style("README.md").dim());
403
404    write_file(&project_path.join("Dockerfile"), &render_template(include_str!("../templates/dockerfile_txt.txt"), &[
405        (PROJECT_NAME_PLACEHOLDER, project_name),
406    ]))?;
407    println!("  {} {}", style("✓").green(), style("Dockerfile").dim());
408
409    write_file(&project_path.join("docker-compose.yml"), &render_template(include_str!("../templates/docker_compose_yml.txt"), &[
410        (PROJECT_NAME_PLACEHOLDER, project_name),
411    ]))?;
412    println!("  {} {}", style("✓").green(), style("docker-compose.yml").dim());
413
414    Ok(())
415}
416
417// ── Template helpers ─────────────────────────────────────────────────────────
418
419fn render_template(content: &str, replacements: &[(&str, &str)]) -> String {
420    let mut result = content.to_string();
421    for (placeholder, value) in replacements {
422        result = result.replace(placeholder, value);
423    }
424    result
425}
426
427fn write_file(path: &Path, content: &str) -> Result<()> {
428    fs::write(path, content).context(format!("Failed to write file: {:?}", path))
429}
430
431/// Convert "my-api" → "my_api"
432fn to_snake_case(s: &str) -> String {
433    let mut result = String::new();
434    for (i, c) in s.chars().enumerate() {
435        if c.is_uppercase() && i > 0 {
436            result.push('_');
437        }
438        result.push(c.to_ascii_lowercase());
439    }
440    result.replace('-', "_").replace(' ', "_")
441}
442
443/// Convert "my-api" → "MyApi"
444fn to_pascal_case(s: &str) -> String {
445    let mut result = String::new();
446    let mut capitalize_next = true;
447    for c in s.chars() {
448        if c == '-' || c == '_' || c == ' ' {
449            capitalize_next = true;
450        } else if capitalize_next {
451            result.extend(c.to_uppercase());
452            capitalize_next = false;
453        } else {
454            result.push(c);
455        }
456    }
457    result
458}
459
460/// Walk up the directory tree from `start` looking for `kegani/Cargo.toml`.
461/// Returns the path to the kegani workspace root if found.
462fn find_kegani_workspace(start: &std::path::Path) -> Option<std::path::PathBuf> {
463    let mut current = Some(start.to_path_buf());
464    while let Some(p) = current {
465        if p.join("kegani/Cargo.toml").exists() {
466            return Some(p);
467        }
468        current = p.parent().map(|q| q.to_path_buf());
469    }
470    None
471}