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, 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 echo template: service (provides EchoService), app (calls EchoService),
38    /// or both (generate app and service projects)
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 = if self.template == ProjectTemplateName::Echo {
82            Some(self.prompt_echo_role(self.role.as_ref())?)
83        } else {
84            None
85        };
86
87        // Resolve effective manufacturer from CLI args and config
88        let manufacturer_owned = self.effective_manufacturer(&cli_config)?;
89        let manufacturer = manufacturer_owned.as_str();
90
91        // role=both requires custom manufacturer to avoid conflicts with public 'acme' services
92        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        // role=service with default manufacturer will register as 'acme:EchoService', which
100        // conflicts with the public echo service on the same signaling server.
101        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        // When role=both, generate both echo-app and echo-service projects
111        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        // Check if target directory exists and is not empty
122        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        // Check if current directory already has manifest.toml
130        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        // Create project directory if needed
138        if project_dir != Path::new(".") {
139            std::fs::create_dir_all(&project_dir)?;
140        }
141
142        // Normalize the signaling URL: strip trailing "/signaling/ws" (and optional "/")
143        // so that each language template can append its own path suffix without duplication.
144        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    /// Resolve the effective manufacturer, applying precedence:
170    /// CLI flag > CLI config default. Ensures the result is non-empty.
171    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            // Initialize in current directory - name will be inferred
195            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            // Create new directory - extract project name from path
216            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    /// Prompt for echo template role when not specified. Returns the role (never prompts if --role was given).
227    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    /// Execute initialization when role=both: generate echo-app and echo-service projects.
267    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        // Determine concrete subdirectories for app and service.
276        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        // Prevent overwriting existing directories.
294        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        // Check if current directory already has manifest.toml when using "."
303        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        // Create parent directory if needed (for non-current-dir case).
311        if parent_dir != Path::new(".") {
312            std::fs::create_dir_all(&parent_dir)?;
313        }
314
315        // Normalize the signaling URL: strip trailing "/signaling/ws" (and optional "/")
316        // so that each language template can append its own path suffix without duplication.
317        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        // Build InitContext for echo-app (role=app).
325        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        // Build InitContext for echo-service (role=service).
337        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        // Generate service first, then app.
349        initialize::execute_initialize(self.language, &service_context).await?;
350        initialize::execute_initialize(self.language, &app_context).await?;
351
352        Ok(())
353    }
354
355    /// Interactive prompt for missing fields with detailed guidance
356    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            // Provide sensible defaults
413            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            // Validate project name if applicable
425            if field_name == "project name" {
426                self.validate_project_name(trimmed)?;
427            }
428            Ok(trimmed.to_string())
429        }
430    }
431
432    /// Validate project name according to requirements
433    fn validate_project_name(&self, name: &str) -> Result<()> {
434        // Check if name is valid: alphanumeric characters, hyphens, and underscores only
435        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        // Check for other common invalid patterns
446        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}