use anyhow::{Context, Result};
use console::{style, Emoji};
use std::fs;
use std::path::Path;
const AUTHOR_PLACEHOLDER: &str = "{{AUTHOR}}";
const PROJECT_NAME_PLACEHOLDER: &str = "{{PROJECT_NAME}}";
const PROJECT_NAME_SNAKE_PLACEHOLDER: &str = "{{PROJECT_NAME_SNAKE}}";
const PROJECT_NAME_PASCAL_PLACEHOLDER: &str = "{{PROJECT_NAME_PASCAL}}";
const RESOURCE_NAME_PLACEHOLDER: &str = "{{RESOURCE_NAME}}";
const RESOURCE_NAME_PASCAL_PLACEHOLDER: &str = "{{RESOURCE_NAME_PASCAL}}";
pub fn init(name: &str, template: &str) -> Result<()> {
let project_path = Path::new(name);
if project_path.exists() && project_path.is_dir() {
let entries = fs::read_dir(project_path)?;
if entries.count() > 0 {
anyhow::bail!(
"Directory '{}' is not empty. Please specify an empty directory or a new name.",
name
);
}
}
if !name.matches(['/', '\\']).take(1).next().is_none() {
if let Some(parent) = project_path.parent() {
if !parent.exists() {
anyhow::bail!("Parent directory does not exist: {}", parent.display());
}
}
}
println!();
println!("{} {}", Emoji("⚡", ""), style("Initializing Kegani project").bold());
println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
println!(" {} {}", style("Project:").dim(), style(name).cyan().bold());
println!(" {} {}", style("Template:").dim(), style(template).cyan().bold());
println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
println!();
if !project_path.exists() {
fs::create_dir_all(project_path).context("Failed to create project directory")?;
}
println!("{} {}", Emoji("📁", ""), style("Created project directory").green());
generate_project_structure(project_path, name)?;
println!();
println!(
"{} {} project '{}' initialized!",
Emoji("🎉", ""),
style("✓").green(),
style(name).cyan().bold()
);
println!();
println!("{}", style("Next steps:").bold());
println!(
" {} {} {}",
style("1.").dim(),
style("cd"),
style(name).cyan()
);
println!(" {} {}", style("2.").dim(), style("cp .env.example .env"));
println!(
" {} {} {}",
style("3.").dim(),
style("cargo run"),
style("(starts on http://127.0.0.1:8080)").dim()
);
println!(" {} {} {}", style("4.").dim(), style("keg gen api"), style("to generate your first resource").dim());
println!();
Ok(())
}
fn generate_project_structure(project_path: &Path, project_name: &str) -> Result<()> {
let snake_name = to_snake_case(project_name);
let pascal_name = to_pascal_case(project_name);
let replacements = &[
(PROJECT_NAME_PLACEHOLDER, project_name),
(PROJECT_NAME_SNAKE_PLACEHOLDER, &snake_name),
(PROJECT_NAME_PASCAL_PLACEHOLDER, &pascal_name),
(AUTHOR_PLACEHOLDER, "Your Name <you@example.com>"),
];
let workspace_root = find_kegani_workspace(&std::env::current_dir()?);
if workspace_root.is_some() {
println!(" {} {}", style("ℹ").dim(), style("Detected kegani workspace — using local path dependency").dim());
}
println!("{} Creating project structure", style("⚙️").cyan());
let src_dir = project_path.join("src");
fs::create_dir_all(&src_dir).context("Failed to create src/")?;
write_file(&src_dir.join("main.rs"), &render_template(include_str!("../templates/src_main_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("src/main.rs").dim());
write_file(&src_dir.join("lib.rs"), &render_template(include_str!("../templates/src_lib_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("src/lib.rs").dim());
write_file(&src_dir.join("error.rs"), &render_template(include_str!("../templates/src_error_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("src/error.rs").dim());
let routes_dir = src_dir.join("routes");
fs::create_dir_all(&routes_dir).context("Failed to create src/routes/")?;
write_file(&routes_dir.join("mod.rs"), &render_template(include_str!("../templates/src_routes_mod_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("src/routes/mod.rs").dim());
let routes_api_dir = routes_dir.join("api");
fs::create_dir_all(&routes_api_dir).context("Failed to create src/routes/api/")?;
write_file(&routes_api_dir.join("mod.rs"), &render_template(include_str!("../templates/src_routes_api_mod_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("src/routes/api/mod.rs").dim());
let routes_api_v1_dir = routes_api_dir.join("v1");
fs::create_dir_all(&routes_api_v1_dir).context("Failed to create src/routes/api/v1/")?;
println!(" {} {}", style("✓").green(), style("src/routes/api/v1/ (empty, populated by keg gen api)").dim());
write_file(&routes_dir.join("health.rs"), &render_template(include_str!("../templates/src_routes_health_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("src/routes/health.rs").dim());
let controller_dir = src_dir.join("controller");
fs::create_dir_all(&controller_dir).context("Failed to create src/controller/")?;
write_file(&controller_dir.join("mod.rs"), &render_template(include_str!("../templates/src_controller_mod_rs.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
]))?;
println!(" {} {}", style("✓").green(), style("src/controller/mod.rs").dim());
let controller_api_dir = controller_dir.join("api");
fs::create_dir_all(&controller_api_dir).context("Failed to create src/controller/api/")?;
write_file(&controller_api_dir.join("mod.rs"), &render_template(include_str!("../templates/src_controller_api_mod_rs.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
(RESOURCE_NAME_PASCAL_PLACEHOLDER, "{{RESOURCE_NAME_PASCAL}}"),
]))?;
println!(" {} {}", style("✓").green(), style("src/controller/api/mod.rs").dim());
let middleware_dir = src_dir.join("middleware");
fs::create_dir_all(&middleware_dir).context("Failed to create src/middleware/")?;
write_file(&middleware_dir.join("mod.rs"), &render_template(include_str!("../templates/src_middleware_mod_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("src/middleware/mod.rs").dim());
write_file(&middleware_dir.join("auth.rs"), &render_template(include_str!("../templates/src_middleware_auth_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("src/middleware/auth.rs").dim());
let internal_dir = project_path.join("internal");
fs::create_dir_all(&internal_dir).context("Failed to create internal/")?;
write_file(&internal_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_mod_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("internal/mod.rs").dim());
let model_dir = internal_dir.join("model");
fs::create_dir_all(&model_dir).context("Failed to create internal/model/")?;
write_file(&model_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_model_mod_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("internal/model/mod.rs").dim());
let entity_dir = model_dir.join("entity");
fs::create_dir_all(&entity_dir).context("Failed to create internal/model/entity/")?;
write_file(&entity_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_model_entity_mod_rs.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
]))?;
println!(" {} {}", style("✓").green(), style("internal/model/entity/mod.rs").dim());
let dto_dir = internal_dir.join("dto");
fs::create_dir_all(&dto_dir).context("Failed to create internal/dto/")?;
write_file(&dto_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_mod_rs.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("internal/dto/mod.rs").dim());
let dto_req_dir = dto_dir.join("requests");
fs::create_dir_all(&dto_req_dir).context("Failed to create internal/dto/requests/")?;
write_file(&dto_req_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_requests_mod_rs.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
]))?;
let dto_resp_dir = dto_dir.join("responses");
fs::create_dir_all(&dto_resp_dir).context("Failed to create internal/dto/responses/")?;
write_file(&dto_resp_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_dto_responses_mod_rs.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
]))?;
let repo_dir = internal_dir.join("repository");
fs::create_dir_all(&repo_dir).context("Failed to create internal/repository/")?;
write_file(&repo_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_repository_mod_rs.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
]))?;
println!(" {} {}", style("✓").green(), style("internal/repository/mod.rs").dim());
let logic_dir = internal_dir.join("logic");
fs::create_dir_all(&logic_dir).context("Failed to create internal/logic/")?;
write_file(&logic_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_logic_mod_rs.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
]))?;
println!(" {} {}", style("✓").green(), style("internal/logic/mod.rs").dim());
let service_dir = internal_dir.join("service");
fs::create_dir_all(&service_dir).context("Failed to create internal/service/")?;
write_file(&service_dir.join("mod.rs"), &render_template(include_str!("../templates/internal_service_mod_rs.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
]))?;
println!(" {} {}", style("✓").green(), style("internal/service/mod.rs").dim());
let manifest_dir = project_path.join("manifest").join("config");
fs::create_dir_all(&manifest_dir).context("Failed to create manifest/config/")?;
write_file(&manifest_dir.join("config.yaml"), &render_template(include_str!("../templates/manifest_config_yaml.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("manifest/config/config.yaml").dim());
write_file(&manifest_dir.join("config.prod.yaml"), &render_template(include_str!("../templates/manifest_config_prod_yaml.txt"), replacements))?;
write_file(&manifest_dir.join("config.test.yaml"), &render_template(include_str!("../templates/manifest_config_test_yaml.txt"), replacements))?;
let resource_dir = project_path.join("resource").join("openapi");
fs::create_dir_all(&resource_dir).context("Failed to create resource/openapi/")?;
write_file(&resource_dir.join("schema.yaml"), &render_template(include_str!("../templates/resource_openapi_schema_yaml.txt"), replacements))?;
println!(" {} {}", style("✓").green(), style("resource/openapi/schema.yaml").dim());
let protocol_dir = project_path.join("protocol").join("protobuf");
fs::create_dir_all(&protocol_dir).context("Failed to create protocol/protobuf/")?;
write_file(&protocol_dir.join("mod.rs"), &render_template(include_str!("../templates/protocol_protobuf_mod_rs.txt"), replacements))?;
let migrations_dir = project_path.join("migrations");
fs::create_dir_all(&migrations_dir).context("Failed to create migrations/")?;
write_file(&migrations_dir.join("001_init.sql"), &render_template(include_str!("../templates/migrations_001_init_sql.txt"), &[
(RESOURCE_NAME_PLACEHOLDER, "{{RESOURCE_NAME}}"),
]))?;
println!(" {} {}", style("✓").green(), style("migrations/001_init.sql").dim());
let tests_dir = project_path.join("tests");
fs::create_dir_all(&tests_dir).context("Failed to create tests/")?;
write_file(&tests_dir.join("api_test.rs"), &render_template(include_str!("../templates/tests_api_test_rs.txt"), replacements))?;
write_file(&tests_dir.join("mod.rs"), include_str!("../templates/tests_mod_rs.txt"))?;
println!(" {} {}", style("✓").green(), style("tests/api_test.rs").dim());
let cargo_toml_path = project_path.join("Cargo.toml");
let cargo_content = render_template(
include_str!("../templates/Cargo.toml.txt"),
&[
(PROJECT_NAME_PLACEHOLDER, project_name),
(PROJECT_NAME_SNAKE_PLACEHOLDER, &snake_name),
(AUTHOR_PLACEHOLDER, "Your Name <you@example.com>"),
],
);
let cargo_content = if let Some(workspace_root) = find_kegani_workspace(&std::env::current_dir()?) {
let dep_line = "kegani = { path = \"../..\" }".to_string();
cargo_content.replace("{{KEGANI_DEP}}", &dep_line)
} else {
cargo_content.replace("{{KEGANI_DEP}}", "kegani = \"0.1\"")
};
write_file(&cargo_toml_path, &cargo_content)?;
println!(" {} {}", style("✓").green(), style("Cargo.toml").dim());
write_file(&project_path.join("config.yaml"), include_str!("../templates/config_yaml.txt"))?;
write_file(&project_path.join(".env.example"), &render_template(include_str!("../templates/env_example_txt.txt"), &[
(PROJECT_NAME_PLACEHOLDER, project_name),
]))?;
write_file(&project_path.join(".env"), "# Copy from .env.example and fill in your values\n")?;
println!(" {} {}", style("✓").green(), style(".env.example").dim());
write_file(&project_path.join(".gitignore"), include_str!("../templates/gitignore_txt.txt"))?;
println!(" {} {}", style("✓").green(), style(".gitignore").dim());
write_file(&project_path.join("README.md"), &render_template(include_str!("../templates/readme_md.txt"), &[
(PROJECT_NAME_PLACEHOLDER, project_name),
]))?;
println!(" {} {}", style("✓").green(), style("README.md").dim());
write_file(&project_path.join("Dockerfile"), &render_template(include_str!("../templates/dockerfile_txt.txt"), &[
(PROJECT_NAME_PLACEHOLDER, project_name),
]))?;
println!(" {} {}", style("✓").green(), style("Dockerfile").dim());
write_file(&project_path.join("docker-compose.yml"), &render_template(include_str!("../templates/docker_compose_yml.txt"), &[
(PROJECT_NAME_PLACEHOLDER, project_name),
]))?;
println!(" {} {}", style("✓").green(), style("docker-compose.yml").dim());
Ok(())
}
fn render_template(content: &str, replacements: &[(&str, &str)]) -> String {
let mut result = content.to_string();
for (placeholder, value) in replacements {
result = result.replace(placeholder, value);
}
result
}
fn write_file(path: &Path, content: &str) -> Result<()> {
fs::write(path, content).context(format!("Failed to write file: {:?}", path))
}
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_ascii_lowercase());
}
result.replace('-', "_").replace(' ', "_")
}
fn to_pascal_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = true;
for c in s.chars() {
if c == '-' || c == '_' || c == ' ' {
capitalize_next = true;
} else if capitalize_next {
result.extend(c.to_uppercase());
capitalize_next = false;
} else {
result.push(c);
}
}
result
}
fn find_kegani_workspace(start: &std::path::Path) -> Option<std::path::PathBuf> {
let mut current = Some(start.to_path_buf());
while let Some(p) = current {
if p.join("kegani/Cargo.toml").exists() {
return Some(p);
}
current = p.parent().map(|q| q.to_path_buf());
}
None
}