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 = self.resolve_template_role()?;
82
83 let manufacturer_owned = self.effective_manufacturer(&cli_config)?;
85 let manufacturer = manufacturer_owned.as_str();
86
87 if matches!(echo_role, Some(EchoRole::Both)) && manufacturer == DEFAULT_MANUFACTURER {
89 return Err(ActrCliError::InvalidProject(
90 "role=both requires a custom manufacturer to avoid conflicts with public 'acme' services.\n\
91 Use: --manufacturer <your-org-name>".to_string(),
92 ));
93 }
94
95 if matches!(echo_role, Some(EchoRole::Service))
98 && self.template == ProjectTemplateName::Echo
99 && manufacturer == DEFAULT_MANUFACTURER
100 {
101 let svc_name = "EchoService";
102 println!(
103 "⚠️ Warning: using default manufacturer 'acme' with role=service will register\n\
104 this service as 'acme:{svc_name}', which conflicts with the public {svc_name}\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 if self.template != ProjectTemplateName::Echo {
113 return Err(ActrCliError::InvalidProject(
114 "role=both is only supported for the echo template".to_string(),
115 ));
116 }
117 self.execute_both(&name, &signaling_url, manufacturer)
118 .await?;
119 return Ok(());
120 }
121
122 let (project_dir, project_name) = self.resolve_project_info(&name)?;
123
124 info!("🚀 Initializing Actor-RTC project: {}", project_name);
125
126 if project_dir.exists() && project_dir != Path::new(".") {
128 return Err(ActrCliError::InvalidProject(format!(
129 "Directory '{}' already exists. Use a different name or remove the existing directory.",
130 project_dir.display()
131 )));
132 }
133
134 if project_dir == Path::new(".") && Path::new("manifest.toml").exists() {
136 return Err(ActrCliError::InvalidProject(
137 "Current directory already contains an ACTR workload project (manifest.toml exists)"
138 .to_string(),
139 ));
140 }
141
142 if project_dir != Path::new(".") {
144 std::fs::create_dir_all(&project_dir)?;
145 }
146
147 let normalized_signaling_url = signaling_url
150 .strip_suffix("/signaling/ws/")
151 .or_else(|| signaling_url.strip_suffix("/signaling/ws"))
152 .unwrap_or(&signaling_url[..])
153 .trim_end_matches('/')
154 .to_string();
155
156 let context = InitContext {
157 project_dir: project_dir.clone(),
158 project_name: project_name.clone(),
159 signaling_url: normalized_signaling_url,
160 template: self.template,
161 is_current_dir: project_dir == Path::new("."),
162 echo_role,
163 manufacturer: manufacturer.to_string(),
164 is_both: false,
165 };
166
167 initialize::execute_initialize(self.language, &context).await?;
168
169 Ok(())
170 }
171}
172
173impl InitCommand {
174 fn resolve_template_role(&self) -> Result<Option<EchoRole>> {
175 match self.template {
176 ProjectTemplateName::Echo => Ok(Some(self.prompt_echo_role(self.role.as_ref())?)),
177 ProjectTemplateName::Empty => match self.language {
178 SupportedLanguage::Rust => self.role_or_default_service("empty template for Rust"),
179 SupportedLanguage::Swift => self.role_or_default_app("empty template for Swift"),
180 SupportedLanguage::Kotlin => Err(ActrCliError::Unsupported(
181 "Empty template is not supported for Kotlin yet".to_string(),
182 )),
183 SupportedLanguage::Python => Err(ActrCliError::Unsupported(
184 "Empty template is not supported for Python yet".to_string(),
185 )),
186 SupportedLanguage::TypeScript => Err(ActrCliError::Unsupported(
187 "Empty template is not supported for TypeScript yet".to_string(),
188 )),
189 },
190 ProjectTemplateName::DataStream => match self.language {
191 SupportedLanguage::Rust => {
192 self.role_or_default_service("data-stream template for Rust")
193 }
194 SupportedLanguage::Swift => {
195 self.role_or_default_app("data-stream template for Swift")
196 }
197 SupportedLanguage::Kotlin => {
198 let role = self.prompt_echo_role(self.role.as_ref())?;
199 match role {
200 EchoRole::Service => Ok(Some(EchoRole::Service)),
201 EchoRole::App => Err(ActrCliError::InvalidProject(
202 "role=app is only supported for the echo template. Use role=service for data-stream."
203 .to_string(),
204 )),
205 EchoRole::Both => Err(ActrCliError::InvalidProject(
206 "role=both is only supported for the echo template".to_string(),
207 )),
208 }
209 }
210 SupportedLanguage::Python => match self.role {
211 Some(EchoRole::Service) => Ok(Some(EchoRole::Service)),
212 _ => Err(ActrCliError::Unsupported(
213 "Python init now generates workload components only; use --role service."
214 .to_string(),
215 )),
216 },
217 SupportedLanguage::TypeScript => Err(ActrCliError::Unsupported(
218 "DataStream template is not supported for TypeScript yet".to_string(),
219 )),
220 },
221 }
222 }
223
224 fn role_or_default_service(&self, label: &str) -> Result<Option<EchoRole>> {
225 match self.role {
226 None | Some(EchoRole::Service) => Ok(Some(EchoRole::Service)),
227 Some(EchoRole::App) | Some(EchoRole::Both) => Err(ActrCliError::InvalidProject(
228 format!("{label} only supports --role service."),
229 )),
230 }
231 }
232
233 fn role_or_default_app(&self, label: &str) -> Result<Option<EchoRole>> {
234 match self.role {
235 None | Some(EchoRole::App) => Ok(Some(EchoRole::App)),
236 Some(EchoRole::Service) | Some(EchoRole::Both) => Err(ActrCliError::InvalidProject(
237 format!("{label} only supports --role app."),
238 )),
239 }
240 }
241
242 pub fn effective_manufacturer(
245 &self,
246 cli_config: &crate::config::resolver::EffectiveCliConfig,
247 ) -> Result<String> {
248 let effective_manufacturer = cli_config.mfr.manufacturer.clone();
249
250 let manufacturer_owned: String = match &self.manufacturer {
251 Some(m) => m.clone(),
252 None => effective_manufacturer,
253 };
254
255 let manufacturer = manufacturer_owned.trim();
256 if manufacturer.is_empty() {
257 return Err(ActrCliError::InvalidProject(
258 "Manufacturer cannot be empty".to_string(),
259 ));
260 }
261
262 Ok(manufacturer.to_string())
263 }
264
265 fn resolve_project_info(&self, name: &str) -> Result<(PathBuf, String)> {
266 if name == "." {
267 let project_name = if let Some(name) = &self.project_name {
269 name.clone()
270 } else {
271 let current_dir = std::env::current_dir().map_err(|e| {
272 ActrCliError::InvalidProject(format!(
273 "Failed to resolve current directory: {e}"
274 ))
275 })?;
276 current_dir
277 .file_name()
278 .and_then(|s| s.to_str())
279 .map(|s| s.to_string())
280 .ok_or_else(|| {
281 ActrCliError::InvalidProject(
282 "Failed to infer project name from current directory".to_string(),
283 )
284 })?
285 };
286 Ok((PathBuf::from("."), project_name))
287 } else {
288 let path = PathBuf::from(name);
290 let project_name = path
291 .file_name()
292 .and_then(|s| s.to_str())
293 .unwrap_or(name)
294 .to_string();
295 Ok((path, project_name))
296 }
297 }
298
299 fn prompt_echo_role(&self, current_value: Option<&EchoRole>) -> Result<EchoRole> {
301 if let Some(role) = current_value {
302 return Ok(*role);
303 }
304
305 println!("┌──────────────────────────────────────────────────────────┐");
306 println!("│ 🎭 Echo Template Role │");
307 println!("├──────────────────────────────────────────────────────────┤");
308 println!("│ │");
309 println!("│ service Provides EchoService, waits for RPC calls │");
310 println!("│ app Calls EchoService, sends echo RPC and exits │");
311 println!("│ both Generates both app and service projects │");
312 println!("│ │");
313 println!("└──────────────────────────────────────────────────────────┘");
314 print!("🎯 Enter role [app]: ");
315
316 io::stdout().flush().map_err(ActrCliError::Io)?;
317
318 let mut input = String::new();
319 io::stdin()
320 .read_line(&mut input)
321 .map_err(ActrCliError::Io)?;
322
323 println!();
324
325 let trimmed = input.trim().to_lowercase();
326 if trimmed.is_empty() || trimmed == "app" {
327 Ok(EchoRole::App)
328 } else if trimmed == "service" {
329 Ok(EchoRole::Service)
330 } else if trimmed == "both" {
331 Ok(EchoRole::Both)
332 } else {
333 Err(ActrCliError::InvalidProject(format!(
334 "Invalid role '{trimmed}'. Use 'service', 'app' or 'both'."
335 )))
336 }
337 }
338
339 async fn execute_both(
341 &self,
342 name: &str,
343 signaling_url: &str,
344 manufacturer: &str,
345 ) -> Result<()> {
346 let (parent_dir, _ignored_project_name) = self.resolve_project_info(name)?;
347
348 let app_dir = if parent_dir == Path::new(".") {
350 PathBuf::from("echo-app")
351 } else {
352 parent_dir.join("echo-app")
353 };
354 let service_dir = if parent_dir == Path::new(".") {
355 PathBuf::from("echo-service")
356 } else {
357 parent_dir.join("echo-service")
358 };
359
360 info!(
361 "🚀 Initializing Actor-RTC echo projects: {} and {}",
362 app_dir.display(),
363 service_dir.display()
364 );
365
366 if app_dir.exists() || service_dir.exists() {
368 return Err(ActrCliError::InvalidProject(format!(
369 "Target directories '{}' or '{}' already exist. Remove them or choose a different project name.",
370 app_dir.display(),
371 service_dir.display()
372 )));
373 }
374
375 if parent_dir == Path::new(".") && Path::new("manifest.toml").exists() {
377 return Err(ActrCliError::InvalidProject(
378 "Current directory already contains an ACTR workload project (manifest.toml exists)"
379 .to_string(),
380 ));
381 }
382
383 if parent_dir != Path::new(".") {
385 std::fs::create_dir_all(&parent_dir)?;
386 }
387
388 let normalized_signaling_url = signaling_url
391 .strip_suffix("/signaling/ws/")
392 .or_else(|| signaling_url.strip_suffix("/signaling/ws"))
393 .unwrap_or(signaling_url)
394 .trim_end_matches('/')
395 .to_string();
396
397 let app_context = InitContext {
399 project_dir: app_dir.clone(),
400 project_name: "echo-app".to_string(),
401 signaling_url: normalized_signaling_url.clone(),
402 template: self.template,
403 is_current_dir: false,
404 echo_role: Some(EchoRole::App),
405 manufacturer: manufacturer.to_string(),
406 is_both: true,
407 };
408
409 let service_context = InitContext {
411 project_dir: service_dir.clone(),
412 project_name: "echo-service".to_string(),
413 signaling_url: normalized_signaling_url,
414 template: self.template,
415 is_current_dir: false,
416 echo_role: Some(EchoRole::Service),
417 manufacturer: manufacturer.to_string(),
418 is_both: true,
419 };
420
421 initialize::execute_initialize(self.language, &service_context).await?;
423 initialize::execute_initialize(self.language, &app_context).await?;
424
425 Ok(())
426 }
427
428 fn prompt_if_missing(
430 &self,
431 field_name: &str,
432 current_value: Option<&String>,
433 ) -> Result<String> {
434 if let Some(value) = current_value {
435 return Ok(value.clone());
436 }
437
438 match field_name {
439 "project name" => {
440 println!("┌──────────────────────────────────────────────────────────┐");
441 println!("│ 📋 Project Name Configuration │");
442 println!("├──────────────────────────────────────────────────────────┤");
443 println!("│ │");
444 println!("│ 📝 Requirements: │");
445 println!("│ • Only alphanumeric characters, hyphens and _ │");
446 println!("│ • Cannot start or end with - or _ │");
447 println!("│ │");
448 println!("│ 💡 Examples: │");
449 println!("│ my-chat-service, user-manager, media_streamer │");
450 println!("│ │");
451 println!("└──────────────────────────────────────────────────────────┘");
452 print!("🎯 Enter project name [my-actor-project]: ");
453 }
454 "signaling server URL" => {
455 println!("┌──────────────────────────────────────────────────────────┐");
456 println!("│ 🌐 Signaling Server Configuration │");
457 println!("├──────────────────────────────────────────────────────────┤");
458 println!("│ │");
459 println!("│ 📡 WebSocket URL for Actor-RTC signaling coordination │");
460 println!("│ │");
461 println!("│ 💡 Examples: │");
462 println!("│ ws://localhost:8080/ (development) │");
463 println!("│ wss://example.com (production │");
464 println!("│ wss://example.com/?token=${{TOKEN}} (with auth) │");
465 println!("│ │");
466 println!("└──────────────────────────────────────────────────────────┘");
467 print!("🎯 Enter signaling server URL [wss://actrix1.develenv.com]: ");
468 }
469 _ => {
470 print!("🎯 Enter {field_name}: ");
471 }
472 }
473
474 io::stdout().flush().map_err(ActrCliError::Io)?;
475
476 let mut input = String::new();
477 io::stdin()
478 .read_line(&mut input)
479 .map_err(ActrCliError::Io)?;
480
481 println!();
482
483 let trimmed = input.trim();
484 if trimmed.is_empty() {
485 let default = match field_name {
487 "project name" => "my-actor-project",
488 "signaling server URL" => "wss://actrix1.develenv.com/signaling/ws",
489 _ => {
490 return Err(ActrCliError::InvalidProject(format!(
491 "{field_name} cannot be empty"
492 )));
493 }
494 };
495 Ok(default.to_string())
496 } else {
497 if field_name == "project name" {
499 self.validate_project_name(trimmed)?;
500 }
501 Ok(trimmed.to_string())
502 }
503 }
504
505 fn validate_project_name(&self, name: &str) -> Result<()> {
507 let is_valid = name
509 .chars()
510 .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
511
512 if !is_valid {
513 return Err(ActrCliError::InvalidProject(format!(
514 "Invalid project name '{name}'. Only alphanumeric characters, hyphens, and underscores are allowed."
515 )));
516 }
517
518 if name.is_empty() {
520 return Err(ActrCliError::InvalidProject(
521 "Project name cannot be empty".to_string(),
522 ));
523 }
524
525 if name.starts_with('-') || name.ends_with('-') {
526 return Err(ActrCliError::InvalidProject(
527 "Project name cannot start or end with a hyphen".to_string(),
528 ));
529 }
530
531 if name.starts_with('_') || name.ends_with('_') {
532 return Err(ActrCliError::InvalidProject(
533 "Project name cannot start or end with an underscore".to_string(),
534 ));
535 }
536
537 Ok(())
538 }
539}