Skip to main content

actr_cli/commands/
init.rs

1//! Project initialization command
2
3use 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    /// Name of the project to create (use '.' for current directory)
18    pub name: Option<String>,
19
20    /// Project template to use (echo, empty, data-stream)
21    #[arg(long, default_value_t = ProjectTemplateName::Echo)]
22    pub template: ProjectTemplateName,
23
24    /// Project name when initializing in current directory
25    #[arg(long)]
26    pub project_name: Option<String>,
27
28    /// Signaling server URL
29    /// TODO: will be removed when manifest.toml strips system fields
30    #[arg(long)]
31    pub signaling: Option<String>,
32
33    /// Target language for project initialization
34    #[arg(short, long, default_value = "rust")]
35    pub language: SupportedLanguage,
36
37    /// Role for project templates. Echo supports service, app, or both. Rust empty/data-stream
38    /// supports service only. Swift empty/data-stream supports app/default only.
39    #[arg(long)]
40    pub role: Option<EchoRole>,
41
42    /// Manufacturer for generated actor types (overrides CLI config default: acme)
43    #[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        // Resolve effective CLI config to use as defaults
70        let cli_config = resolve_effective_cli_config().unwrap_or_default();
71
72        // Show welcome header
73        println!("🎯 Actor-RTC Project Initialization");
74        println!("----------------------------------------");
75
76        // Interactive prompt for missing required fields
77        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        // Resolve effective manufacturer from CLI args and config
84        let manufacturer_owned = self.effective_manufacturer(&cli_config)?;
85        let manufacturer = manufacturer_owned.as_str();
86
87        // role=both requires custom manufacturer to avoid conflicts with public 'acme' services
88        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        // role=service with default manufacturer will register under the default manufacturer,
96        // which may conflict with public services on the same signaling server.
97        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        // When role=both, generate both echo-app and echo-service projects.
111        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        // Check if target directory exists and is not empty
127        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        // Check if current directory already has manifest.toml
135        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        // Create project directory if needed
143        if project_dir != Path::new(".") {
144            std::fs::create_dir_all(&project_dir)?;
145        }
146
147        // Normalize the signaling URL: strip trailing "/signaling/ws" (and optional "/")
148        // so that each language template can append its own path suffix without duplication.
149        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    /// Resolve the effective manufacturer, applying precedence:
243    /// CLI flag > CLI config default. Ensures the result is non-empty.
244    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            // Initialize in current directory - name will be inferred
268            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            // Create new directory - extract project name from path
289            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    /// Prompt for echo template role when not specified. Returns the role (never prompts if --role was given).
300    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    /// Execute initialization when role=both: generate echo-app and echo-service projects.
340    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        // Determine concrete subdirectories for app and service.
349        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        // Prevent overwriting existing directories.
367        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        // Check if current directory already has manifest.toml when using "."
376        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        // Create parent directory if needed (for non-current-dir case).
384        if parent_dir != Path::new(".") {
385            std::fs::create_dir_all(&parent_dir)?;
386        }
387
388        // Normalize the signaling URL: strip trailing "/signaling/ws" (and optional "/")
389        // so that each language template can append its own path suffix without duplication.
390        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        // Build InitContext for echo-app (role=app).
398        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        // Build InitContext for echo-service (role=service).
410        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        // Generate service first, then app.
422        initialize::execute_initialize(self.language, &service_context).await?;
423        initialize::execute_initialize(self.language, &app_context).await?;
424
425        Ok(())
426    }
427
428    /// Interactive prompt for missing fields with detailed guidance
429    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            // Provide sensible defaults
486            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            // Validate project name if applicable
498            if field_name == "project name" {
499                self.validate_project_name(trimmed)?;
500            }
501            Ok(trimmed.to_string())
502        }
503    }
504
505    /// Validate project name according to requirements
506    fn validate_project_name(&self, name: &str) -> Result<()> {
507        // Check if name is valid: alphanumeric characters, hyphens, and underscores only
508        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        // Check for other common invalid patterns
519        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}