bun_cli/
generator.rs

1use crate::error::{BunCliError, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5/// Configuration for project generation
6pub struct ProjectConfig {
7    pub name: String,
8    pub dependencies: Vec<String>,
9}
10
11impl Default for ProjectConfig {
12    fn default() -> Self {
13        Self {
14            name: String::new(),
15            dependencies: vec![
16                "@bogeychan/elysia-logger".to_string(),
17                "@elysiajs/cors".to_string(),
18                "@elysiajs/swagger".to_string(),
19                "@sentry/bun".to_string(),
20                "@sentry/cli".to_string(),
21                "@types/luxon".to_string(),
22                "jsonwebtoken".to_string(),
23                "luxon".to_string(),
24                "mongoose".to_string(),
25                "winston".to_string(),
26                "winston-daily-rotate-file".to_string(),
27            ],
28        }
29    }
30}
31
32/// Generator for creating Bun projects
33pub struct ProjectGenerator {
34    config: ProjectConfig,
35}
36
37impl ProjectGenerator {
38    /// Create a new project generator with the given configuration
39    pub fn new(config: ProjectConfig) -> Self {
40        Self { config }
41    }
42
43    /// Validate the project name
44    fn validate_project_name(&self) -> Result<()> {
45        let name = self.config.name.trim();
46        
47        if name.is_empty() {
48            return Err(BunCliError::InvalidProjectName(
49                "Project name cannot be empty".to_string(),
50            ));
51        }
52
53        // Check for invalid characters (basic validation)
54        if name.contains(['/', '\\', '\0']) {
55            return Err(BunCliError::InvalidProjectName(
56                format!("Project name '{name}' contains invalid characters"),
57            ));
58        }
59
60        Ok(())
61    }
62
63    /// Check if Bun is installed on the system
64    pub fn check_bun_installed() -> Result<()> {
65        let output = Command::new("bun")
66            .arg("--version")
67            .output()
68            .map_err(|_| BunCliError::BunNotInstalled)?;
69
70        if !output.status.success() {
71            return Err(BunCliError::BunNotInstalled);
72        }
73
74        Ok(())
75    }
76
77    /// Install Bun using the official install script
78    pub fn install_bun() -> Result<()> {
79        println!("Installing Bun...");
80        
81        let install_cmd = "curl -fsSL https://bun.sh/install | bash";
82        
83        let output = Command::new("sh")
84            .arg("-c")
85            .arg(install_cmd)
86            .output()
87            .map_err(|e| BunCliError::CommandFailed {
88                command: "install_bun".to_string(),
89                message: e.to_string(),
90            })?;
91
92        if !output.status.success() {
93            let error_message = String::from_utf8_lossy(&output.stderr);
94            return Err(BunCliError::CommandFailed {
95                command: "install_bun".to_string(),
96                message: error_message.to_string(),
97            });
98        }
99
100        println!("✓ Bun installed successfully");
101        Ok(())
102    }
103
104    /// Create the base project using bun create
105    fn create_base_project(&self) -> Result<()> {
106        let output = Command::new("bun")
107            .arg("create")
108            .arg("elysia")
109            .arg(&self.config.name)
110            .output()?;
111
112        if !output.status.success() {
113            let error_message = String::from_utf8_lossy(&output.stderr);
114            return Err(BunCliError::CommandFailed {
115                command: format!("bun create elysia {}", self.config.name),
116                message: error_message.to_string(),
117            });
118        }
119
120        Ok(())
121    }
122
123    /// Install a single dependency
124    fn install_dependency(&self, dep: &str) -> Result<()> {
125        let output = Command::new("bun")
126            .arg("add")
127            .arg(dep)
128            .current_dir(&self.config.name)
129            .output()?;
130
131        if !output.status.success() {
132            let error_message = String::from_utf8_lossy(&output.stderr);
133            return Err(BunCliError::DependencyFailed {
134                dependency: dep.to_string(),
135                message: error_message.to_string(),
136            });
137        }
138
139        Ok(())
140    }
141
142    /// Install all dependencies
143    fn install_dependencies(&self) -> Result<()> {
144        for dep in &self.config.dependencies {
145            // Attempt to install, but continue if one fails
146            match self.install_dependency(dep) {
147                Ok(()) => println!("✓ Added dependency: {dep}"),
148                Err(e) => eprintln!("⚠ Warning: {e}"),
149            }
150        }
151        Ok(())
152    }
153
154    /// Copy template files to the project
155    fn copy_templates(&self) -> Result<()> {
156        // Get the source template directory
157        // In a binary distribution, templates would be embedded or in a known location
158        let template_src = Path::new(env!("CARGO_MANIFEST_DIR"))
159            .join("src")
160            .join("templates")
161            .join("src");
162
163        if !template_src.exists() {
164            // Templates don't exist, skip copying
165            return Ok(());
166        }
167
168        let dest = PathBuf::from(&self.config.name).join("src");
169
170        // Use a cross-platform approach to copy files
171        Self::copy_dir_recursive(&template_src, &dest)?;
172
173        Ok(())
174    }
175
176    /// Recursively copy directory contents (cross-platform)
177    fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
178        // Create destination directory if it doesn't exist
179        if !dst.exists() {
180            std::fs::create_dir_all(dst)?;
181        }
182
183        for entry in std::fs::read_dir(src)? {
184            let entry = entry?;
185            let file_type = entry.file_type()?;
186            let src_path = entry.path();
187            let dst_path = dst.join(entry.file_name());
188
189            if file_type.is_dir() {
190                Self::copy_dir_recursive(&src_path, &dst_path)?;
191            } else {
192                // Only copy if destination doesn't exist or is different
193                let should_copy = if !dst_path.exists() {
194                    true
195                } else {
196                    // Both src and dst exist, compare metadata
197                    match (std::fs::metadata(&src_path), std::fs::metadata(&dst_path)) {
198                        (Ok(src_metadata), Ok(dst_metadata)) => {
199                            src_metadata.len() != dst_metadata.len()
200                                || src_metadata.modified()? > dst_metadata.modified()?
201                        }
202                        _ => true, // If we can't read metadata, copy to be safe
203                    }
204                };
205
206                if should_copy {
207                    std::fs::copy(&src_path, &dst_path)?;
208                }
209            }
210        }
211
212        Ok(())
213    }
214
215    /// Generate the complete project
216    pub fn generate(&self) -> Result<()> {
217        // Validate project name
218        self.validate_project_name()?;
219
220        // Check if Bun is installed
221        Self::check_bun_installed()?;
222
223        // Create base project
224        let project_name = &self.config.name;
225        println!("Creating project '{project_name}'...");
226        self.create_base_project()?;
227        println!("✓ Project '{project_name}' created successfully");
228
229        // Install dependencies
230        println!("Installing dependencies...");
231        self.install_dependencies()?;
232
233        // Copy templates
234        println!("Copying template files...");
235        match self.copy_templates() {
236            Ok(()) => println!("✓ Template files copied successfully"),
237            Err(e) => eprintln!("⚠ Warning: {e}"),
238        }
239
240        Ok(())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_validate_empty_project_name() {
250        let config = ProjectConfig {
251            name: "".to_string(),
252            dependencies: vec![],
253        };
254        let generator = ProjectGenerator::new(config);
255        
256        let result = generator.validate_project_name();
257        assert!(result.is_err());
258        assert!(
259            matches!(result, Err(BunCliError::InvalidProjectName(_))),
260            "Expected InvalidProjectName error, got: {:?}",
261            result
262        );
263    }
264
265    #[test]
266    fn test_validate_project_name_with_slash() {
267        let config = ProjectConfig {
268            name: "my/project".to_string(),
269            dependencies: vec![],
270        };
271        let generator = ProjectGenerator::new(config);
272        
273        let result = generator.validate_project_name();
274        assert!(result.is_err());
275    }
276
277    #[test]
278    fn test_validate_project_name_with_backslash() {
279        let config = ProjectConfig {
280            name: "my\\project".to_string(),
281            dependencies: vec![],
282        };
283        let generator = ProjectGenerator::new(config);
284        
285        let result = generator.validate_project_name();
286        assert!(result.is_err());
287    }
288
289    #[test]
290    fn test_validate_valid_project_name() {
291        let config = ProjectConfig {
292            name: "my-cool-project".to_string(),
293            dependencies: vec![],
294        };
295        let generator = ProjectGenerator::new(config);
296        
297        let result = generator.validate_project_name();
298        assert!(result.is_ok());
299    }
300
301    #[test]
302    fn test_default_config_has_dependencies() {
303        let config = ProjectConfig::default();
304        assert!(!config.dependencies.is_empty());
305        assert!(config.dependencies.contains(&"@elysiajs/cors".to_string()));
306    }
307}
308