1use crate::error::{BunCliError, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5pub 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
32pub struct ProjectGenerator {
34 config: ProjectConfig,
35}
36
37impl ProjectGenerator {
38 pub fn new(config: ProjectConfig) -> Self {
40 Self { config }
41 }
42
43 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 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 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 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 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 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 fn install_dependencies(&self) -> Result<()> {
144 for dep in &self.config.dependencies {
145 match self.install_dependency(dep) {
147 Ok(()) => println!("✓ Added dependency: {dep}"),
148 Err(e) => eprintln!("⚠ Warning: {e}"),
149 }
150 }
151 Ok(())
152 }
153
154 fn copy_templates(&self) -> Result<()> {
156 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 return Ok(());
166 }
167
168 let dest = PathBuf::from(&self.config.name).join("src");
169
170 Self::copy_dir_recursive(&template_src, &dest)?;
172
173 Ok(())
174 }
175
176 fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
178 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 let should_copy = if !dst_path.exists() {
194 true
195 } else {
196 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, }
204 };
205
206 if should_copy {
207 std::fs::copy(&src_path, &dst_path)?;
208 }
209 }
210 }
211
212 Ok(())
213 }
214
215 pub fn generate(&self) -> Result<()> {
217 self.validate_project_name()?;
219
220 Self::check_bun_installed()?;
222
223 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 println!("Installing dependencies...");
231 self.install_dependencies()?;
232
233 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