actr_cli/commands/
init.rs

1//! Project initialization command
2
3use crate::commands::initialize::{self, InitContext};
4use crate::commands::{Command, SupportedLanguage};
5use crate::error::{ActrCliError, Result};
6use crate::template::ProjectTemplateName;
7use async_trait::async_trait;
8use clap::Args;
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use tracing::info;
12
13#[derive(Args)]
14pub struct InitCommand {
15    /// Name of the project to create (use '.' for current directory)
16    pub name: Option<String>,
17
18    /// Project template to use (echo)
19    #[arg(long, default_value_t = ProjectTemplateName::Echo)]
20    pub template: ProjectTemplateName,
21
22    /// Project name when initializing in current directory
23    #[arg(long)]
24    pub project_name: Option<String>,
25
26    /// Signaling server URL
27    #[arg(long)]
28    pub signaling: Option<String>,
29
30    /// Target language for project initialization
31    #[arg(short, long, default_value = "rust")]
32    pub language: SupportedLanguage,
33}
34
35#[async_trait]
36impl Command for InitCommand {
37    async fn execute(&self) -> Result<()> {
38        // Show welcome header
39        println!("🎯 Actor-RTC Project Initialization");
40        println!("----------------------------------------");
41
42        // Interactive prompt for missing required fields
43        let name = self.prompt_if_missing("project name", self.name.as_ref())?;
44
45        // For Kotlin/Swift/Python, use default signaling URL if not provided
46        let signaling_url = match self.language {
47            SupportedLanguage::Kotlin | SupportedLanguage::Swift | SupportedLanguage::Python => {
48                self.signaling
49                    .clone()
50                    .unwrap_or_else(|| "wss://actrix1.develenv.com/signaling/ws".to_string())
51            }
52            SupportedLanguage::Rust => {
53                self.prompt_if_missing("signaling server URL", self.signaling.as_ref())?
54            }
55        };
56
57        let (project_dir, project_name) = self.resolve_project_info(&name)?;
58
59        info!("🚀 Initializing Actor-RTC project: {}", project_name);
60
61        // Check if target directory exists and is not empty
62        if project_dir.exists() && project_dir != Path::new(".") {
63            return Err(ActrCliError::InvalidProject(format!(
64                "Directory '{}' already exists. Use a different name or remove the existing directory.",
65                project_dir.display()
66            )));
67        }
68
69        // Check if current directory already has Actr.toml
70        if project_dir == Path::new(".") && Path::new("Actr.toml").exists() {
71            return Err(ActrCliError::InvalidProject(
72                "Current directory already contains an Actor-RTC project (Actr.toml exists)"
73                    .to_string(),
74            ));
75        }
76
77        // Create project directory if needed
78        if project_dir != Path::new(".") {
79            std::fs::create_dir_all(&project_dir)?;
80        }
81
82        if self.language != SupportedLanguage::Rust {
83            let context = InitContext {
84                project_dir: project_dir.clone(),
85                project_name: project_name.clone(),
86                signaling_url: signaling_url.clone(),
87                template: self.template,
88                is_current_dir: project_dir == Path::new("."),
89            };
90            initialize::execute_initialize(self.language, &context).await?;
91            info!(
92                "✅ Successfully created Actor-RTC project '{}'",
93                project_name
94            );
95            return Ok(());
96        }
97
98        // Generate project structure
99        self.generate_project_structure(
100            &project_dir,
101            &project_name,
102            &signaling_url,
103            self.template,
104        )?;
105
106        info!(
107            "✅ Successfully created Actor-RTC project '{}'",
108            project_name
109        );
110        if project_dir != Path::new(".") {
111            info!("📁 Project created in: {}", project_dir.display());
112            info!("");
113            info!("Next steps:");
114            info!("  cd {}/client", project_dir.display());
115            info!("  actr install  # Install remote protobuf dependencies from Actr.toml");
116            info!("  actr gen                             # Generate Actor code");
117            info!("  cargo run                            # Start your work");
118        } else {
119            info!("📁 Project initialized in current directory");
120            info!("");
121            info!("Next steps:");
122            info!("  actr install  # Install remote protobuf dependencies from Actr.toml");
123            info!("  actr gen                             # Generate Actor code");
124            info!("  cargo run                            # Start your work");
125        }
126
127        Ok(())
128    }
129}
130
131impl InitCommand {
132    fn resolve_project_info(&self, name: &str) -> Result<(PathBuf, String)> {
133        if name == "." {
134            // Initialize in current directory - cargo will determine the name
135            let project_name = if let Some(name) = &self.project_name {
136                name.clone()
137            } else {
138                let current_dir = std::env::current_dir().map_err(|e| {
139                    ActrCliError::InvalidProject(format!(
140                        "Failed to resolve current directory: {e}"
141                    ))
142                })?;
143                current_dir
144                    .file_name()
145                    .and_then(|s| s.to_str())
146                    .map(|s| s.to_string())
147                    .ok_or_else(|| {
148                        ActrCliError::InvalidProject(
149                            "Failed to infer project name from current directory".to_string(),
150                        )
151                    })?
152            };
153            Ok((PathBuf::from("."), project_name))
154        } else {
155            // Create new directory - extract project name from path
156            let path = PathBuf::from(name);
157            let project_name = path
158                .file_name()
159                .and_then(|s| s.to_str())
160                .unwrap_or(name)
161                .to_string();
162            Ok((path, project_name))
163        }
164    }
165
166    fn generate_project_structure(
167        &self,
168        project_dir: &Path,
169        project_name: &str,
170        signaling_url: &str,
171        template: ProjectTemplateName,
172    ) -> Result<()> {
173        // Always use cargo init for all scenarios
174        if project_dir == Path::new(".") {
175            // Current directory init - let cargo handle naming
176            self.init_with_cargo(project_dir, None, signaling_url, template)?;
177        } else {
178            // New directory - create it and use cargo init with explicit name
179            std::fs::create_dir_all(project_dir)?;
180            self.init_with_cargo(project_dir, Some(project_name), signaling_url, template)?;
181        }
182
183        Ok(())
184    }
185
186    fn create_actr_config(
187        &self,
188        project_dir: &Path,
189        project_name: &str,
190        signaling_url: &str,
191    ) -> Result<()> {
192        let service_type = format!("{project_name}-service");
193
194        // Create Actr.toml directly as string (Config doesn't have default_template or save_to_file)
195        let actr_toml_content = format!(
196            r#"edition = 1
197exports = []
198
199[package]
200name = "{project_name}"
201manufacturer = "my-company"
202type = "{service_type}"
203description = "An Actor-RTC service"
204authors = []
205
206[dependencies]
207
208[system.signaling]
209url = "{signaling_url}"
210
211[system.deployment]
212realm_id = 1001
213
214[system.discovery]
215visible = true
216
217[scripts]
218dev = "cargo run"
219test = "cargo test"
220"#
221        );
222
223        std::fs::write(project_dir.join("Actr.toml"), actr_toml_content)?;
224
225        info!("📄 Created Actr.toml configuration");
226        Ok(())
227    }
228
229    fn create_gitignore(&self, project_dir: &Path) -> Result<()> {
230        let gitignore_content = r#"/target
231/Cargo.lock
232.env
233.env.local
234*.log
235.DS_Store
236/src/generated/
237"#;
238
239        std::fs::write(project_dir.join(".gitignore"), gitignore_content)?;
240
241        info!("📄 Created .gitignore");
242        Ok(())
243    }
244
245    /// Interactive prompt for missing fields with detailed guidance
246    fn prompt_if_missing(
247        &self,
248        field_name: &str,
249        current_value: Option<&String>,
250    ) -> Result<String> {
251        if let Some(value) = current_value {
252            return Ok(value.clone());
253        }
254
255        match field_name {
256            "project name" => {
257                println!("┌──────────────────────────────────────────────────────────┐");
258                println!("│ 📋  Project Name Configuration                           │");
259                println!("├──────────────────────────────────────────────────────────┤");
260                println!("│                                                          │");
261                println!("│  📝 Requirements:                                        │");
262                println!("│     • Only alphanumeric characters, hyphens and _        │");
263                println!("│     • Cannot start or end with - or _                    │");
264                println!("│                                                          │");
265                println!("│  💡 Examples:                                            │");
266                println!("│     my-chat-service, user-manager, media_streamer        │");
267                println!("│                                                          │");
268                println!("└──────────────────────────────────────────────────────────┘");
269                print!("🎯 Enter project name [my-actor-project]: ");
270            }
271            "signaling server URL" => {
272                println!("┌──────────────────────────────────────────────────────────┐");
273                println!("│ 🌐  Signaling Server Configuration                       │");
274                println!("├──────────────────────────────────────────────────────────┤");
275                println!("│                                                          │");
276                println!("│  📡 WebSocket URL for Actor-RTC signaling coordination   │");
277                println!("│                                                          │");
278                println!("│  💡 Examples:                                            │");
279                println!("│     ws://localhost:8080/                (development)    │");
280                println!("│     wss://example.com                   (production      │");
281                println!("│     wss://example.com/?token=${{TOKEN}}   (with auth)    │");
282                println!("│                                                          │");
283                println!("└──────────────────────────────────────────────────────────┘");
284                print!("🎯 Enter signaling server URL [wss://actrix1.develenv.com]: ");
285            }
286            _ => {
287                print!("🎯 Enter {field_name}: ");
288            }
289        }
290
291        io::stdout().flush().map_err(ActrCliError::Io)?;
292
293        let mut input = String::new();
294        io::stdin()
295            .read_line(&mut input)
296            .map_err(ActrCliError::Io)?;
297
298        println!();
299
300        let trimmed = input.trim();
301        if trimmed.is_empty() {
302            // Provide sensible defaults
303            let default = match field_name {
304                "project name" => "my-actor-project",
305                "signaling server URL" => "wss://actrix1.develenv.com",
306                _ => {
307                    return Err(ActrCliError::InvalidProject(format!(
308                        "{field_name} cannot be empty"
309                    )));
310                }
311            };
312            Ok(default.to_string())
313        } else {
314            // Validate project name if applicable
315            if field_name == "project name" {
316                self.validate_project_name(trimmed)?;
317            }
318            Ok(trimmed.to_string())
319        }
320    }
321
322    /// Validate project name according to requirements
323    fn validate_project_name(&self, name: &str) -> Result<()> {
324        // Check if name is valid: alphanumeric characters, hyphens, and underscores only
325        let is_valid = name
326            .chars()
327            .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
328
329        if !is_valid {
330            return Err(ActrCliError::InvalidProject(format!(
331                "Invalid project name '{name}'. Only alphanumeric characters, hyphens, and underscores are allowed."
332            )));
333        }
334
335        // Check for other common invalid patterns
336        if name.is_empty() {
337            return Err(ActrCliError::InvalidProject(
338                "Project name cannot be empty".to_string(),
339            ));
340        }
341
342        if name.starts_with('-') || name.ends_with('-') {
343            return Err(ActrCliError::InvalidProject(
344                "Project name cannot start or end with a hyphen".to_string(),
345            ));
346        }
347
348        if name.starts_with('_') || name.ends_with('_') {
349            return Err(ActrCliError::InvalidProject(
350                "Project name cannot start or end with an underscore".to_string(),
351            ));
352        }
353
354        Ok(())
355    }
356
357    /// Initialize using cargo init, then enhance for Actor-RTC
358    fn init_with_cargo(
359        &self,
360        project_dir: &Path,
361        explicit_name: Option<&str>,
362        signaling_url: &str,
363        template: ProjectTemplateName,
364    ) -> Result<()> {
365        info!("🚀 Initializing Rust project with cargo...");
366
367        // Step 1: Run cargo init - let it handle all validation
368        let mut cmd = std::process::Command::new("cargo");
369        cmd.arg("init").arg("--quiet").current_dir(project_dir);
370
371        // Add explicit name if provided (for new directories)
372        if let Some(name) = explicit_name {
373            cmd.arg("--name").arg(name);
374        }
375
376        let cargo_result = cmd
377            .output()
378            .map_err(|e| ActrCliError::Command(format!("Failed to run cargo init: {e}")))?;
379
380        if !cargo_result.status.success() {
381            let error_msg = String::from_utf8_lossy(&cargo_result.stderr);
382            return Err(ActrCliError::Command(format!(
383                "cargo init failed: {error_msg}"
384            )));
385        }
386
387        // Step 2: Read the project name that cargo determined
388        let project_name = self.extract_project_name_from_cargo_toml(project_dir)?;
389        info!("📦 Rust project initialized: '{}'", project_name);
390
391        // Step 3: Enhance with Actor-RTC specific files
392        self.enhance_cargo_project_for_actr(project_dir, &project_name, signaling_url, template)?;
393
394        Ok(())
395    }
396
397    /// Extract project name from Cargo.toml generated by cargo init
398    fn extract_project_name_from_cargo_toml(&self, project_dir: &Path) -> Result<String> {
399        let cargo_toml_path = project_dir.join("Cargo.toml");
400        let cargo_content = std::fs::read_to_string(&cargo_toml_path).map_err(ActrCliError::Io)?;
401
402        // Parse TOML to extract project name
403        for line in cargo_content.lines() {
404            if line.trim().starts_with("name = ")
405                && let Some(name_part) = line.split('=').nth(1)
406            {
407                let name = name_part.trim().trim_matches('"').trim_matches('\'');
408                return Ok(name.to_string());
409            }
410        }
411
412        // Fallback to directory name if parsing fails
413        Ok("actor-service".to_string())
414    }
415
416    /// Enhance cargo-generated project with Actor-RTC specific features
417    fn enhance_cargo_project_for_actr(
418        &self,
419        project_dir: &Path,
420        project_name: &str,
421        signaling_url: &str,
422        template: ProjectTemplateName,
423    ) -> Result<()> {
424        info!("⚡ Enhancing with Actor-RTC features...");
425
426        // Create proto directory
427        let proto_dir = project_dir.join("protos");
428        std::fs::create_dir_all(&proto_dir)?;
429        info!("📁 Created protos/ directory");
430
431        // Create local.proto file using template
432        crate::commands::initialize::create_local_proto(
433            project_dir,
434            project_name,
435            "protos/local",
436            template,
437        )?;
438
439        // Generate Actr.toml
440        self.create_actr_config(project_dir, project_name, signaling_url)?;
441        info!("📄 Created Actr.toml configuration");
442
443        // Enhance Cargo.toml with Actor-RTC dependencies
444        self.enhance_cargo_toml_with_actr_deps(project_dir)?;
445        info!("📦 Enhanced Cargo.toml with Actor-RTC dependencies");
446
447        // Create .gitignore if it doesn't exist
448        let gitignore_path = project_dir.join(".gitignore");
449        if !gitignore_path.exists() {
450            self.create_gitignore(project_dir)?;
451            info!("📄 Created .gitignore");
452        }
453
454        Ok(())
455    }
456
457    /// Add Actor-RTC dependencies to existing Cargo.toml
458    fn enhance_cargo_toml_with_actr_deps(&self, project_dir: &Path) -> Result<()> {
459        let cargo_toml_path = project_dir.join("Cargo.toml");
460        let mut cargo_content =
461            std::fs::read_to_string(&cargo_toml_path).map_err(ActrCliError::Io)?;
462
463        // Add Actor-RTC dependencies if not already present
464        if !cargo_content.contains("actr-core") {
465            cargo_content.push_str("\n# Actor-RTC Framework Dependencies\n");
466            cargo_content.push_str("actr-core = { path = \"../actr-core\" }\n");
467            cargo_content.push_str("tokio = { version = \"1.0\", features = [\"full\"] }\n");
468
469            std::fs::write(&cargo_toml_path, cargo_content).map_err(ActrCliError::Io)?;
470        }
471
472        Ok(())
473    }
474}