1use crate::commands::SupportedLanguage;
4use crate::commands::initialize::{self, InitContext};
5use crate::config::resolver::resolve_effective_cli_config;
6use crate::core::{Command, CommandContext, CommandResult, ComponentType};
7use crate::error::{ActrCliError, Result};
8use crate::template::{DEFAULT_MANUFACTURER, EchoRole, ProjectTemplateName};
9use async_trait::async_trait;
10use clap::Args;
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13use tracing::info;
14
15#[derive(Args)]
16pub struct InitCommand {
17 pub name: Option<String>,
19
20 #[arg(long, default_value_t = ProjectTemplateName::Echo)]
22 pub template: ProjectTemplateName,
23
24 #[arg(long)]
26 pub project_name: Option<String>,
27
28 #[arg(long)]
31 pub signaling: Option<String>,
32
33 #[arg(short, long, default_value = "rust")]
35 pub language: SupportedLanguage,
36
37 #[arg(long)]
40 pub role: Option<EchoRole>,
41
42 #[arg(long)]
44 manufacturer: Option<String>,
45}
46
47#[async_trait]
48impl Command for InitCommand {
49 async fn execute(&self, _ctx: &CommandContext) -> anyhow::Result<CommandResult> {
50 self.execute_inner().await.map_err(anyhow::Error::from)?;
51 Ok(CommandResult::Success("Project initialized".to_string()))
52 }
53
54 fn required_components(&self) -> Vec<ComponentType> {
55 vec![]
56 }
57
58 fn name(&self) -> &str {
59 "init"
60 }
61
62 fn description(&self) -> &str {
63 "Initialize a new Actor project"
64 }
65}
66
67impl InitCommand {
68 async fn execute_inner(&self) -> Result<()> {
69 let cli_config = resolve_effective_cli_config().unwrap_or_default();
71
72 println!("🎯 Actor-RTC Project Initialization");
74 println!("----------------------------------------");
75
76 let name = self.prompt_if_missing("project name", self.name.as_ref())?;
78 let signaling_url =
79 self.prompt_if_missing("signaling server URL", self.signaling.as_ref())?;
80
81 let echo_role = if self.template == ProjectTemplateName::Echo {
82 Some(self.prompt_echo_role(self.role.as_ref())?)
83 } else {
84 None
85 };
86
87 let manufacturer_owned = self.effective_manufacturer(&cli_config)?;
89 let manufacturer = manufacturer_owned.as_str();
90
91 if matches!(echo_role, Some(EchoRole::Both)) && manufacturer == DEFAULT_MANUFACTURER {
93 return Err(ActrCliError::InvalidProject(
94 "role=both requires a custom manufacturer to avoid conflicts with public 'acme' services.\n\
95 Use: --manufacturer <your-org-name>".to_string(),
96 ));
97 }
98
99 if matches!(echo_role, Some(EchoRole::Service)) && manufacturer == DEFAULT_MANUFACTURER {
102 println!(
103 "⚠️ Warning: using default manufacturer 'acme' with role=service will register\n\
104 this service as 'acme:EchoService', which conflicts with the public echo service\n\
105 on the same signaling server and may cause interference.\n\
106 Consider using a custom manufacturer: --manufacturer <your-org-name>"
107 );
108 }
109
110 if matches!(echo_role, Some(EchoRole::Both)) {
112 self.execute_both(&name, &signaling_url, manufacturer)
113 .await?;
114 return Ok(());
115 }
116
117 let (project_dir, project_name) = self.resolve_project_info(&name)?;
118
119 info!("🚀 Initializing Actor-RTC project: {}", project_name);
120
121 if project_dir.exists() && project_dir != Path::new(".") {
123 return Err(ActrCliError::InvalidProject(format!(
124 "Directory '{}' already exists. Use a different name or remove the existing directory.",
125 project_dir.display()
126 )));
127 }
128
129 if project_dir == Path::new(".") && Path::new("manifest.toml").exists() {
131 return Err(ActrCliError::InvalidProject(
132 "Current directory already contains an ACTR workload project (manifest.toml exists)"
133 .to_string(),
134 ));
135 }
136
137 if project_dir != Path::new(".") {
139 std::fs::create_dir_all(&project_dir)?;
140 }
141
142 let normalized_signaling_url = signaling_url
145 .strip_suffix("/signaling/ws/")
146 .or_else(|| signaling_url.strip_suffix("/signaling/ws"))
147 .unwrap_or(&signaling_url[..])
148 .trim_end_matches('/')
149 .to_string();
150
151 let context = InitContext {
152 project_dir: project_dir.clone(),
153 project_name: project_name.clone(),
154 signaling_url: normalized_signaling_url,
155 template: self.template,
156 is_current_dir: project_dir == Path::new("."),
157 echo_role,
158 manufacturer: manufacturer.to_string(),
159 is_both: false,
160 };
161
162 initialize::execute_initialize(self.language, &context).await?;
163
164 Ok(())
165 }
166}
167
168impl InitCommand {
169 pub fn effective_manufacturer(
172 &self,
173 cli_config: &crate::config::resolver::EffectiveCliConfig,
174 ) -> Result<String> {
175 let effective_manufacturer = cli_config.mfr.manufacturer.clone();
176
177 let manufacturer_owned: String = match &self.manufacturer {
178 Some(m) => m.clone(),
179 None => effective_manufacturer,
180 };
181
182 let manufacturer = manufacturer_owned.trim();
183 if manufacturer.is_empty() {
184 return Err(ActrCliError::InvalidProject(
185 "Manufacturer cannot be empty".to_string(),
186 ));
187 }
188
189 Ok(manufacturer.to_string())
190 }
191
192 fn resolve_project_info(&self, name: &str) -> Result<(PathBuf, String)> {
193 if name == "." {
194 let project_name = if let Some(name) = &self.project_name {
196 name.clone()
197 } else {
198 let current_dir = std::env::current_dir().map_err(|e| {
199 ActrCliError::InvalidProject(format!(
200 "Failed to resolve current directory: {e}"
201 ))
202 })?;
203 current_dir
204 .file_name()
205 .and_then(|s| s.to_str())
206 .map(|s| s.to_string())
207 .ok_or_else(|| {
208 ActrCliError::InvalidProject(
209 "Failed to infer project name from current directory".to_string(),
210 )
211 })?
212 };
213 Ok((PathBuf::from("."), project_name))
214 } else {
215 let path = PathBuf::from(name);
217 let project_name = path
218 .file_name()
219 .and_then(|s| s.to_str())
220 .unwrap_or(name)
221 .to_string();
222 Ok((path, project_name))
223 }
224 }
225
226 fn prompt_echo_role(&self, current_value: Option<&EchoRole>) -> Result<EchoRole> {
228 if let Some(role) = current_value {
229 return Ok(*role);
230 }
231
232 println!("┌──────────────────────────────────────────────────────────┐");
233 println!("│ 🎭 Echo Template Role │");
234 println!("├──────────────────────────────────────────────────────────┤");
235 println!("│ │");
236 println!("│ service Provides EchoService, waits for RPC calls │");
237 println!("│ app Calls EchoService, sends echo RPC and exits │");
238 println!("│ both Generates both app and service projects │");
239 println!("│ │");
240 println!("└──────────────────────────────────────────────────────────┘");
241 print!("🎯 Enter role [app]: ");
242
243 io::stdout().flush().map_err(ActrCliError::Io)?;
244
245 let mut input = String::new();
246 io::stdin()
247 .read_line(&mut input)
248 .map_err(ActrCliError::Io)?;
249
250 println!();
251
252 let trimmed = input.trim().to_lowercase();
253 if trimmed.is_empty() || trimmed == "app" {
254 Ok(EchoRole::App)
255 } else if trimmed == "service" {
256 Ok(EchoRole::Service)
257 } else if trimmed == "both" {
258 Ok(EchoRole::Both)
259 } else {
260 Err(ActrCliError::InvalidProject(format!(
261 "Invalid role '{trimmed}'. Use 'service', 'app' or 'both'."
262 )))
263 }
264 }
265
266 async fn execute_both(
268 &self,
269 name: &str,
270 signaling_url: &str,
271 manufacturer: &str,
272 ) -> Result<()> {
273 let (parent_dir, _ignored_project_name) = self.resolve_project_info(name)?;
274
275 let app_dir = if parent_dir == Path::new(".") {
277 PathBuf::from("echo-app")
278 } else {
279 parent_dir.join("echo-app")
280 };
281 let service_dir = if parent_dir == Path::new(".") {
282 PathBuf::from("echo-service")
283 } else {
284 parent_dir.join("echo-service")
285 };
286
287 info!(
288 "🚀 Initializing Actor-RTC echo projects: {} and {}",
289 app_dir.display(),
290 service_dir.display()
291 );
292
293 if app_dir.exists() || service_dir.exists() {
295 return Err(ActrCliError::InvalidProject(format!(
296 "Target directories '{}' or '{}' already exist. Remove them or choose a different project name.",
297 app_dir.display(),
298 service_dir.display()
299 )));
300 }
301
302 if parent_dir == Path::new(".") && Path::new("manifest.toml").exists() {
304 return Err(ActrCliError::InvalidProject(
305 "Current directory already contains an ACTR workload project (manifest.toml exists)"
306 .to_string(),
307 ));
308 }
309
310 if parent_dir != Path::new(".") {
312 std::fs::create_dir_all(&parent_dir)?;
313 }
314
315 let normalized_signaling_url = signaling_url
318 .strip_suffix("/signaling/ws/")
319 .or_else(|| signaling_url.strip_suffix("/signaling/ws"))
320 .unwrap_or(signaling_url)
321 .trim_end_matches('/')
322 .to_string();
323
324 let app_context = InitContext {
326 project_dir: app_dir.clone(),
327 project_name: "echo-app".to_string(),
328 signaling_url: normalized_signaling_url.clone(),
329 template: self.template,
330 is_current_dir: false,
331 echo_role: Some(EchoRole::App),
332 manufacturer: manufacturer.to_string(),
333 is_both: true,
334 };
335
336 let service_context = InitContext {
338 project_dir: service_dir.clone(),
339 project_name: "echo-service".to_string(),
340 signaling_url: normalized_signaling_url,
341 template: self.template,
342 is_current_dir: false,
343 echo_role: Some(EchoRole::Service),
344 manufacturer: manufacturer.to_string(),
345 is_both: true,
346 };
347
348 initialize::execute_initialize(self.language, &service_context).await?;
350 initialize::execute_initialize(self.language, &app_context).await?;
351
352 Ok(())
353 }
354
355 fn prompt_if_missing(
357 &self,
358 field_name: &str,
359 current_value: Option<&String>,
360 ) -> Result<String> {
361 if let Some(value) = current_value {
362 return Ok(value.clone());
363 }
364
365 match field_name {
366 "project name" => {
367 println!("┌──────────────────────────────────────────────────────────┐");
368 println!("│ 📋 Project Name Configuration │");
369 println!("├──────────────────────────────────────────────────────────┤");
370 println!("│ │");
371 println!("│ 📝 Requirements: │");
372 println!("│ • Only alphanumeric characters, hyphens and _ │");
373 println!("│ • Cannot start or end with - or _ │");
374 println!("│ │");
375 println!("│ 💡 Examples: │");
376 println!("│ my-chat-service, user-manager, media_streamer │");
377 println!("│ │");
378 println!("└──────────────────────────────────────────────────────────┘");
379 print!("🎯 Enter project name [my-actor-project]: ");
380 }
381 "signaling server URL" => {
382 println!("┌──────────────────────────────────────────────────────────┐");
383 println!("│ 🌐 Signaling Server Configuration │");
384 println!("├──────────────────────────────────────────────────────────┤");
385 println!("│ │");
386 println!("│ 📡 WebSocket URL for Actor-RTC signaling coordination │");
387 println!("│ │");
388 println!("│ 💡 Examples: │");
389 println!("│ ws://localhost:8080/ (development) │");
390 println!("│ wss://example.com (production │");
391 println!("│ wss://example.com/?token=${{TOKEN}} (with auth) │");
392 println!("│ │");
393 println!("└──────────────────────────────────────────────────────────┘");
394 print!("🎯 Enter signaling server URL [wss://actrix1.develenv.com]: ");
395 }
396 _ => {
397 print!("🎯 Enter {field_name}: ");
398 }
399 }
400
401 io::stdout().flush().map_err(ActrCliError::Io)?;
402
403 let mut input = String::new();
404 io::stdin()
405 .read_line(&mut input)
406 .map_err(ActrCliError::Io)?;
407
408 println!();
409
410 let trimmed = input.trim();
411 if trimmed.is_empty() {
412 let default = match field_name {
414 "project name" => "my-actor-project",
415 "signaling server URL" => "wss://actrix1.develenv.com/signaling/ws",
416 _ => {
417 return Err(ActrCliError::InvalidProject(format!(
418 "{field_name} cannot be empty"
419 )));
420 }
421 };
422 Ok(default.to_string())
423 } else {
424 if field_name == "project name" {
426 self.validate_project_name(trimmed)?;
427 }
428 Ok(trimmed.to_string())
429 }
430 }
431
432 fn validate_project_name(&self, name: &str) -> Result<()> {
434 let is_valid = name
436 .chars()
437 .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
438
439 if !is_valid {
440 return Err(ActrCliError::InvalidProject(format!(
441 "Invalid project name '{name}'. Only alphanumeric characters, hyphens, and underscores are allowed."
442 )));
443 }
444
445 if name.is_empty() {
447 return Err(ActrCliError::InvalidProject(
448 "Project name cannot be empty".to_string(),
449 ));
450 }
451
452 if name.starts_with('-') || name.ends_with('-') {
453 return Err(ActrCliError::InvalidProject(
454 "Project name cannot start or end with a hyphen".to_string(),
455 ));
456 }
457
458 if name.starts_with('_') || name.ends_with('_') {
459 return Err(ActrCliError::InvalidProject(
460 "Project name cannot start or end with an underscore".to_string(),
461 ));
462 }
463
464 Ok(())
465 }
466}