1use anyhow::{Context, Result};
12use console::{style, Emoji};
13use std::fs;
14use std::io::{self, Write};
15use std::path::Path;
16
17const 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
25pub 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
54pub 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
67fn 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
94pub fn init(name: &str, template: &str, interactive: bool) -> Result<()> {
96 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 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 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 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(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
417fn 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
431fn 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
443fn 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
460fn 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}