Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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();
}
}
}
59 changes: 59 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -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<io::Error> for BunCliError {
fn from(err: io::Error) -> Self {
BunCliError::Io(err)
}
}

pub type Result<T> = std::result::Result<T, BunCliError>;
281 changes: 281 additions & 0 deletions src/generator.rs
Original file line number Diff line number Diff line change
@@ -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<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(),
],
}
}
}

/// 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()));
}
}

8 changes: 8 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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};
Loading