actr_cli/commands/
init.rs

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