diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..50ce078 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,48 @@ +use crate::error::Result; +use crate::generator::{ProjectConfig, ProjectGenerator}; +use std::io; + +/// CLI interface for the bun-cli tool +pub struct Cli; + +impl Cli { + /// Run the CLI application + pub fn run() -> Result<()> { + println!("šŸ¦€ Bun CLI Generator"); + println!("A Cool name for your Bun project šŸ˜Ž:"); + + let project_name = Self::read_project_name()?; + + let config = ProjectConfig { + name: project_name.clone(), + ..Default::default() + }; + + let generator = ProjectGenerator::new(config); + generator.generate()?; + + println!("\n🄳 All done! Your project is ready to use."); + println!("Run 'cd {project_name}' to get started!"); + + Ok(()) + } + + /// Read project name from stdin + fn read_project_name() -> Result { + let mut project_name = String::new(); + io::stdin().read_line(&mut project_name)?; + Ok(project_name.trim().to_string()) + } + + /// Display an error message + pub fn display_error(error: &dyn std::error::Error) { + eprintln!("\nāŒ Error: {error}"); + + // Display the chain of errors if available + let mut source = error.source(); + while let Some(err) = source { + eprintln!(" Caused by: {err}"); + source = err.source(); + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..a6e3a37 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,59 @@ +use std::fmt; +use std::io; + +/// Custom error types for the bun-cli application +#[derive(Debug)] +pub enum BunCliError { + /// IO error occurred + Io(io::Error), + /// Command execution failed + CommandFailed { command: String, message: String }, + /// Invalid project name + InvalidProjectName(String), + /// Bun is not installed + BunNotInstalled, + /// Template copy failed + TemplateCopyFailed(String), + /// Dependency installation failed + DependencyFailed { dependency: String, message: String }, +} + +impl fmt::Display for BunCliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BunCliError::Io(err) => write!(f, "IO error: {}", err), + BunCliError::CommandFailed { command, message } => { + write!(f, "Command '{}' failed: {}", command, message) + } + BunCliError::InvalidProjectName(name) => { + write!(f, "Invalid project name '{}': must not be empty and should contain only valid characters", name) + } + BunCliError::BunNotInstalled => { + write!(f, "Bun is not installed. Please install Bun globally from https://bun.sh") + } + BunCliError::TemplateCopyFailed(message) => { + write!(f, "Failed to copy templates: {}", message) + } + BunCliError::DependencyFailed { dependency, message } => { + write!(f, "Failed to install dependency '{}': {}", dependency, message) + } + } + } +} + +impl std::error::Error for BunCliError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + BunCliError::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for BunCliError { + fn from(err: io::Error) -> Self { + BunCliError::Io(err) + } +} + +pub type Result = std::result::Result; diff --git a/src/generator.rs b/src/generator.rs new file mode 100644 index 0000000..e92b2e3 --- /dev/null +++ b/src/generator.rs @@ -0,0 +1,281 @@ +use crate::error::{BunCliError, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Configuration for project generation +pub struct ProjectConfig { + pub name: String, + pub dependencies: Vec, +} + +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(), + ], + } + } +} + +/// Generator for creating Bun projects +pub struct ProjectGenerator { + config: ProjectConfig, +} + +impl ProjectGenerator { + /// Create a new project generator with the given configuration + pub fn new(config: ProjectConfig) -> Self { + Self { config } + } + + /// Validate the project name + 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(), + )); + } + + // Check for invalid characters (basic validation) + if name.contains(['/', '\\', '\0']) { + return Err(BunCliError::InvalidProjectName( + format!("Project name '{name}' contains invalid characters"), + )); + } + + Ok(()) + } + + /// Check if Bun is installed on the system + 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(()) + } + + /// Create the base project using bun create + 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(()) + } + + /// Install a single dependency + 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(()) + } + + /// Install all dependencies + fn install_dependencies(&self) -> Result<()> { + for dep in &self.config.dependencies { + // Attempt to install, but continue if one fails + match self.install_dependency(dep) { + Ok(()) => println!("āœ“ Added dependency: {dep}"), + Err(e) => eprintln!("⚠ Warning: {e}"), + } + } + Ok(()) + } + + /// Copy template files to the project + fn copy_templates(&self) -> Result<()> { + // Get the source template directory + // In a binary distribution, templates would be embedded or in a known location + let template_src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("templates") + .join("src"); + + if !template_src.exists() { + // Templates don't exist, skip copying + return Ok(()); + } + + let dest = PathBuf::from(&self.config.name).join("src"); + + // Use a cross-platform approach to copy files + Self::copy_dir_recursive(&template_src, &dest)?; + + Ok(()) + } + + /// Recursively copy directory contents (cross-platform) + fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + // Create destination directory if it doesn't exist + 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 { + // Only copy if destination doesn't exist or is different + let should_copy = if !dst_path.exists() { + true + } else { + // Both src and dst exist, compare metadata + 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 we can't read metadata, copy to be safe + } + }; + + if should_copy { + std::fs::copy(&src_path, &dst_path)?; + } + } + } + + Ok(()) + } + + /// Generate the complete project + pub fn generate(&self) -> Result<()> { + // Validate project name + self.validate_project_name()?; + + // Check if Bun is installed + Self::check_bun_installed()?; + + // Create base project + let project_name = &self.config.name; + println!("Creating project '{project_name}'..."); + self.create_base_project()?; + println!("āœ“ Project '{project_name}' created successfully"); + + // Install dependencies + println!("Installing dependencies..."); + self.install_dependencies()?; + + // Copy templates + 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())); + } +} + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9a795db --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +pub mod cli; +pub mod error; +pub mod generator; + +// Re-export commonly used types +pub use cli::Cli; +pub use error::{BunCliError, Result}; +pub use generator::{ProjectConfig, ProjectGenerator}; diff --git a/src/main.rs b/src/main.rs index 19f7a9c..6c3d684 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,62 +1,9 @@ -use std::io; -use std::process::Command; +use bun_cli::Cli; +use std::process; fn main() { - println!("A Cool name for your Bun project šŸ˜Ž:"); - - let mut project_name = String::new(); - io::stdin() - .read_line(&mut project_name) - .expect("Generic error message that doesn't help you at all 🤣"); - - let project_name = project_name.trim(); - - // Generate project - let output = Command::new("bun") - .arg("create") - .arg("elysia") - .arg(&project_name) - .output() - .expect( - "Failed to execute bun create command 😭. Make sure you have bun installed globally šŸ‘€" - ); - - if output.status.success() { - println!("Project '{}' created successfully 🄳", project_name); - // Add dependencies - let deps = [ - "@bogeychan/elysia-logger", - "@elysiajs/cors", - "@elysiajs/swagger", - "@sentry/bun", - "@sentry/cli", - "@types/luxon", - "jsonwebtoken", - "luxon", - "mongoose", - "winston", - "winston-daily-rotate-file", - ]; - - for dep in deps.iter() { - let _ = Command::new("bun") - .arg("add") - .arg(dep) - .current_dir(project_name) - .output() - .expect("Failed to add dependency"); - println!("Added dependency: {}", dep); - } - - // Display each command in the terminal for debugging purposes - let command = format!("xcopy src\\templates\\src {}\\src /s /e /y", project_name); - println!("Running command: {}", command); - let _ = Command::new("cmd") - .args(&["/C", &command]) - .output() - .expect("Failed to copy template"); - } else { - let error_message = String::from_utf8_lossy(&output.stderr); - println!("Failed to create project, should I be a designer instead? šŸ¤”šŸ„ŗ: {}", error_message); + if let Err(e) = Cli::run() { + Cli::display_error(&e); + process::exit(1); } }