use crate::error::{BunCliError, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct ProjectConfig {
pub name: String,
pub dependencies: Vec<String>,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
name: String::new(),
dependencies: vec![
"@bogeychan/elysia-logger".to_string(),
"@elysiajs/cors".to_string(),
"@elysiajs/swagger".to_string(),
"@sentry/bun".to_string(),
"@sentry/cli".to_string(),
"@types/luxon".to_string(),
"jsonwebtoken".to_string(),
"luxon".to_string(),
"mongoose".to_string(),
"winston".to_string(),
"winston-daily-rotate-file".to_string(),
],
}
}
}
pub struct ProjectGenerator {
config: ProjectConfig,
}
impl ProjectGenerator {
pub fn new(config: ProjectConfig) -> Self {
Self { config }
}
fn validate_project_name(&self) -> Result<()> {
let name = self.config.name.trim();
if name.is_empty() {
return Err(BunCliError::InvalidProjectName(
"Project name cannot be empty".to_string(),
));
}
if name.contains(['/', '\\', '\0']) {
return Err(BunCliError::InvalidProjectName(
format!("Project name '{name}' contains invalid characters"),
));
}
Ok(())
}
pub fn check_bun_installed() -> Result<()> {
let output = Command::new("bun")
.arg("--version")
.output()
.map_err(|_| BunCliError::BunNotInstalled)?;
if !output.status.success() {
return Err(BunCliError::BunNotInstalled);
}
Ok(())
}
pub fn install_bun() -> Result<()> {
println!("Installing Bun...");
let install_cmd = "curl -fsSL https://bun.sh/install | bash";
let output = Command::new("sh")
.arg("-c")
.arg(install_cmd)
.output()
.map_err(|e| BunCliError::CommandFailed {
command: "install_bun".to_string(),
message: e.to_string(),
})?;
if !output.status.success() {
let error_message = String::from_utf8_lossy(&output.stderr);
return Err(BunCliError::CommandFailed {
command: "install_bun".to_string(),
message: error_message.to_string(),
});
}
println!("✓ Bun installed successfully");
Ok(())
}
fn create_base_project(&self) -> Result<()> {
let output = Command::new("bun")
.arg("create")
.arg("elysia")
.arg(&self.config.name)
.output()?;
if !output.status.success() {
let error_message = String::from_utf8_lossy(&output.stderr);
return Err(BunCliError::CommandFailed {
command: format!("bun create elysia {}", self.config.name),
message: error_message.to_string(),
});
}
Ok(())
}
fn install_dependency(&self, dep: &str) -> Result<()> {
let output = Command::new("bun")
.arg("add")
.arg(dep)
.current_dir(&self.config.name)
.output()?;
if !output.status.success() {
let error_message = String::from_utf8_lossy(&output.stderr);
return Err(BunCliError::DependencyFailed {
dependency: dep.to_string(),
message: error_message.to_string(),
});
}
Ok(())
}
fn install_dependencies(&self) -> Result<()> {
for dep in &self.config.dependencies {
match self.install_dependency(dep) {
Ok(()) => println!("✓ Added dependency: {dep}"),
Err(e) => eprintln!("⚠ Warning: {e}"),
}
}
Ok(())
}
fn copy_templates(&self) -> Result<()> {
let template_src = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("templates")
.join("src");
if !template_src.exists() {
return Ok(());
}
let dest = PathBuf::from(&self.config.name).join("src");
Self::copy_dir_recursive(&template_src, &dest)?;
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
if !dst.exists() {
std::fs::create_dir_all(dst)?;
}
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if file_type.is_dir() {
Self::copy_dir_recursive(&src_path, &dst_path)?;
} else {
let should_copy = if !dst_path.exists() {
true
} else {
match (std::fs::metadata(&src_path), std::fs::metadata(&dst_path)) {
(Ok(src_metadata), Ok(dst_metadata)) => {
src_metadata.len() != dst_metadata.len()
|| src_metadata.modified()? > dst_metadata.modified()?
}
_ => true, }
};
if should_copy {
std::fs::copy(&src_path, &dst_path)?;
}
}
}
Ok(())
}
pub fn generate(&self) -> Result<()> {
self.validate_project_name()?;
Self::check_bun_installed()?;
let project_name = &self.config.name;
println!("Creating project '{project_name}'...");
self.create_base_project()?;
println!("✓ Project '{project_name}' created successfully");
println!("Installing dependencies...");
self.install_dependencies()?;
println!("Copying template files...");
match self.copy_templates() {
Ok(()) => println!("✓ Template files copied successfully"),
Err(e) => eprintln!("⚠ Warning: {e}"),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_empty_project_name() {
let config = ProjectConfig {
name: "".to_string(),
dependencies: vec![],
};
let generator = ProjectGenerator::new(config);
let result = generator.validate_project_name();
assert!(result.is_err());
assert!(
matches!(result, Err(BunCliError::InvalidProjectName(_))),
"Expected InvalidProjectName error, got: {:?}",
result
);
}
#[test]
fn test_validate_project_name_with_slash() {
let config = ProjectConfig {
name: "my/project".to_string(),
dependencies: vec![],
};
let generator = ProjectGenerator::new(config);
let result = generator.validate_project_name();
assert!(result.is_err());
}
#[test]
fn test_validate_project_name_with_backslash() {
let config = ProjectConfig {
name: "my\\project".to_string(),
dependencies: vec![],
};
let generator = ProjectGenerator::new(config);
let result = generator.validate_project_name();
assert!(result.is_err());
}
#[test]
fn test_validate_valid_project_name() {
let config = ProjectConfig {
name: "my-cool-project".to_string(),
dependencies: vec![],
};
let generator = ProjectGenerator::new(config);
let result = generator.validate_project_name();
assert!(result.is_ok());
}
#[test]
fn test_default_config_has_dependencies() {
let config = ProjectConfig::default();
assert!(!config.dependencies.is_empty());
assert!(config.dependencies.contains(&"@elysiajs/cors".to_string()));
}
}