Skip to main content

spikard_cli/init/
engine.rs

1//! Orchestration engine for project initialization.
2//!
3//! This module provides the `InitEngine` which manages the end-to-end
4//! initialization workflow: request validation, scaffolder selection,
5//! file creation, and user guidance generation.
6
7use crate::codegen::TargetLanguage;
8use anyhow::{Context, Result, bail};
9use std::path::PathBuf;
10use thiserror::Error;
11
12use super::scaffolder::ScaffoldedFile;
13
14/// Errors that can occur during project initialization.
15///
16/// # Variants
17///
18/// - `InvalidProjectName`: The project name does not conform to naming rules
19/// - `DirectoryAlreadyExists`: The target directory already exists
20/// - `SchemaPathNotFound`: A schema path was specified but does not exist
21/// - `ScaffoldingFailed`: An error occurred during file generation or writing
22#[derive(Debug, Error)]
23pub enum InitError {
24    /// Project name does not conform to language-specific naming conventions
25    #[error("Invalid project name '{name}': {reason}")]
26    InvalidProjectName { name: String, reason: String },
27
28    /// The target directory already exists and init should not overwrite it
29    #[error("Directory '{path}' already exists; initialize in a new directory")]
30    DirectoryAlreadyExists { path: PathBuf },
31
32    /// The provided schema path does not exist or cannot be read
33    #[error("Schema file not found: {path}")]
34    SchemaPathNotFound { path: PathBuf },
35
36    /// An error occurred during scaffolding or file creation
37    #[error("Scaffolding failed: {reason}")]
38    ScaffoldingFailed { reason: String },
39}
40
41/// Request to initialize a new Spikard project.
42///
43/// # Fields
44///
45/// - `project_name`: The name of the project (used for packages, modules, etc.)
46/// - `language`: Target implementation language
47/// - `project_dir`: Root directory where the project will be created
48/// - `schema_path`: Optional path to an existing API schema to include in setup
49///
50/// # Example
51///
52/// ```ignore
53/// use spikard_cli::init::InitRequest;
54/// use spikard_cli::codegen::TargetLanguage;
55/// use std::path::PathBuf;
56///
57/// let request = InitRequest {
58///     project_name: "my_api".to_string(),
59///     language: TargetLanguage::Python,
60///     project_dir: PathBuf::from("."),
61///     schema_path: Some(PathBuf::from("openapi.json")),
62/// };
63/// ```
64#[derive(Debug, Clone)]
65pub struct InitRequest {
66    /// The name of the project to be created
67    pub project_name: String,
68    /// Target programming language for the project
69    pub language: TargetLanguage,
70    /// Directory where the project will be initialized
71    pub project_dir: PathBuf,
72    /// Optional path to an existing schema to include in setup
73    pub schema_path: Option<PathBuf>,
74}
75
76/// Response from a successful project initialization.
77///
78/// # Fields
79///
80/// - `files_created`: Paths to all files that were created
81/// - `next_steps`: User-friendly instructions for what to do next
82///
83/// # Example
84///
85/// ```ignore
86/// let response = InitEngine::execute(request)?;
87/// println!("Created {} files", response.files_created.len());
88/// for step in &response.next_steps {
89///     println!("  → {}", step);
90/// }
91/// ```
92#[derive(Debug, Clone, serde::Serialize)]
93pub struct InitResponse {
94    /// Absolute paths to all files that were created
95    pub files_created: Vec<PathBuf>,
96    /// Next steps to guide the user (e.g., "cd `my_api`", "pip install", etc.)
97    pub next_steps: Vec<String>,
98}
99
100/// Orchestrates the project initialization workflow.
101///
102/// # Overview
103///
104/// `InitEngine` is the main entry point for the `spikard init` command.
105/// It handles:
106///
107/// 1. **Validation**: Ensures project name and paths are valid
108/// 2. **Scaffolder Selection**: Routes to the correct language scaffolder
109/// 3. **File Creation**: Writes scaffolded files to disk
110/// 4. **Guidance**: Returns user-friendly next steps
111///
112/// # Validation Rules
113///
114/// - **Project Name**: Must be a valid identifier in the target language
115/// - **Directory**: The project directory must not already exist
116/// - **Schema Path**: If provided, must exist and be readable
117///
118/// # Architecture
119///
120/// The engine does not generate code itself; instead, it delegates to
121/// language-specific `ProjectScaffolder` implementations. This keeps
122/// the engine lightweight and allows independent evolution of language support.
123///
124/// # Example
125///
126/// ```ignore
127/// use spikard_cli::init::{InitEngine, InitRequest};
128/// use spikard_cli::codegen::TargetLanguage;
129/// use std::path::PathBuf;
130///
131/// let request = InitRequest {
132///     project_name: "my_api".to_string(),
133///     language: TargetLanguage::Python,
134///     project_dir: PathBuf::from("."),
135///     schema_path: None,
136/// };
137///
138/// match InitEngine::execute(request) {
139///     Ok(response) => {
140///         println!("Successfully created {} files", response.files_created.len());
141///         for step in response.next_steps {
142///             println!("  → {}", step);
143///         }
144///     }
145///     Err(e) => eprintln!("Initialization failed: {}", e),
146/// }
147/// ```
148pub struct InitEngine;
149
150impl InitEngine {
151    /// Execute the project initialization workflow.
152    ///
153    /// This method is the primary entry point for initializing a new Spikard project.
154    /// It validates the request, selects the appropriate scaffolder, generates files,
155    /// writes them to disk, and returns guidance for next steps.
156    ///
157    /// # Arguments
158    ///
159    /// - `request`: An `InitRequest` specifying project name, language, and location
160    ///
161    /// # Returns
162    ///
163    /// On success, returns an `InitResponse` with created file paths and next steps.
164    /// On failure, returns an error detailing what went wrong.
165    ///
166    /// # Errors
167    ///
168    /// - `InvalidProjectName`: If the project name is not valid for the target language
169    /// - `DirectoryAlreadyExists`: If the project directory already exists
170    /// - `SchemaPathNotFound`: If a schema path was provided but doesn't exist
171    /// - `ScaffoldingFailed`: If file creation or writing fails
172    ///
173    /// # Side Effects
174    ///
175    /// This method creates the project directory and all scaffolded files on disk.
176    /// If any error occurs after directory creation, the directory is left as-is
177    /// for the user to clean up (to avoid accidental data loss).
178    ///
179    /// # Example
180    ///
181    /// ```ignore
182    /// let request = InitRequest {
183    ///     project_name: "my_api".to_string(),
184    ///     language: TargetLanguage::Python,
185    ///     project_dir: PathBuf::from("."),
186    ///     schema_path: None,
187    /// };
188    ///
189    /// let response = InitEngine::execute(request)?;
190    /// # Ok::<(), anyhow::Error>(())
191    /// ```
192    pub fn execute(request: InitRequest) -> Result<InitResponse> {
193        // Validate request inputs
194        Self::validate_request(&request).context("Project initialization request validation failed")?;
195
196        // Get the appropriate scaffolder for the language
197        let scaffolder = Self::get_scaffolder(request.language);
198
199        // Generate files via scaffolder
200        let files = scaffolder
201            .scaffold(&request.project_dir, &request.project_name)
202            .context("Failed to scaffold project files")?;
203
204        // Create project directory
205        std::fs::create_dir_all(&request.project_dir).context(format!(
206            "Failed to create project directory: {}",
207            request.project_dir.display()
208        ))?;
209
210        // Write files to disk and collect paths
211        let mut files_created = Vec::new();
212        for file in files {
213            let full_path = request.project_dir.join(&file.path);
214
215            // Create parent directories if needed
216            if let Some(parent) = full_path.parent() {
217                std::fs::create_dir_all(parent).context(format!("Failed to create directory: {}", parent.display()))?;
218            }
219
220            // Write file content
221            std::fs::write(&full_path, &file.content)
222                .context(format!("Failed to write file: {}", full_path.display()))?;
223
224            files_created.push(full_path);
225        }
226
227        // Get next steps from scaffolder
228        let next_steps = scaffolder.next_steps(&request.project_name);
229
230        Ok(InitResponse {
231            files_created,
232            next_steps,
233        })
234    }
235
236    /// Get the appropriate scaffolder for a language
237    fn get_scaffolder(language: TargetLanguage) -> Box<dyn super::scaffolder::ProjectScaffolder> {
238        match language {
239            TargetLanguage::Python => Box::new(super::python::PythonScaffolder),
240            TargetLanguage::TypeScript => Box::new(super::typescript::TypeScriptScaffolder),
241            TargetLanguage::Rust => Box::new(super::rust_lang::RustScaffolder),
242            TargetLanguage::Ruby => Box::new(super::ruby::RubyScaffolder),
243            TargetLanguage::Php => Box::new(super::php::PhpScaffolder),
244            TargetLanguage::Elixir => Box::new(super::elixir::ElixirScaffolder),
245        }
246    }
247
248    /// Validate the initialization request.
249    ///
250    /// This method checks:
251    ///
252    /// - Project name is valid for the target language
253    /// - Project directory doesn't already exist
254    /// - Schema path (if provided) exists and is accessible
255    ///
256    /// # Arguments
257    ///
258    /// - `request`: The `InitRequest` to validate
259    ///
260    /// # Returns
261    ///
262    /// Returns `Ok(())` if all validations pass, otherwise returns an appropriate error.
263    ///
264    /// # Errors
265    ///
266    /// Returns validation errors with context about what failed.
267    fn validate_request(request: &InitRequest) -> Result<()> {
268        // Validate project name format
269        Self::validate_project_name(&request.project_name, request.language)
270            .context("Project name validation failed")?;
271
272        // Validate project directory doesn't already exist
273        if request.project_dir.exists() {
274            bail!(InitError::DirectoryAlreadyExists {
275                path: request.project_dir.clone(),
276            });
277        }
278
279        // Validate schema path if provided
280        if let Some(schema_path) = &request.schema_path
281            && !schema_path.exists()
282        {
283            bail!(InitError::SchemaPathNotFound {
284                path: schema_path.clone(),
285            });
286        }
287
288        Ok(())
289    }
290
291    /// Validate that a project name is appropriate for the target language.
292    ///
293    /// Naming rules vary by language:
294    ///
295    /// - **Python**: Lowercase, alphanumeric + underscore, no leading digit
296    /// - **TypeScript**: Must be valid npm package name (lowercase, hyphen OK)
297    /// - **Ruby**: `Snake_case`, no leading digit
298    /// - **Rust**: `Snake_case`, alphanumeric + underscore, no leading digit
299    /// - **PHP**: Alphanumeric + underscore, no leading digit
300    ///
301    /// # Arguments
302    ///
303    /// - `project_name`: The name to validate
304    /// - `language`: The target language whose rules apply
305    ///
306    /// # Returns
307    ///
308    /// Returns `Ok(())` if the name is valid, otherwise returns a descriptive error.
309    pub fn validate_project_name(project_name: &str, language: TargetLanguage) -> Result<()> {
310        if project_name.is_empty() {
311            bail!(InitError::InvalidProjectName {
312                name: project_name.to_string(),
313                reason: "Project name cannot be empty".to_string(),
314            });
315        }
316
317        match language {
318            TargetLanguage::Python => {
319                // Python: lowercase letters, digits, underscores; no leading digit
320                if !project_name
321                    .chars()
322                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
323                {
324                    bail!(InitError::InvalidProjectName {
325                        name: project_name.to_string(),
326                        reason: "Python project names must contain only lowercase letters, digits, and underscores"
327                            .to_string(),
328                    });
329                }
330                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
331                    bail!(InitError::InvalidProjectName {
332                        name: project_name.to_string(),
333                        reason: "Python project names cannot start with a digit".to_string(),
334                    });
335                }
336            }
337            TargetLanguage::TypeScript => {
338                // npm package name rules (simplified): lowercase, alphanumeric, hyphens
339                if !project_name
340                    .chars()
341                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
342                {
343                    bail!(InitError::InvalidProjectName {
344                        name: project_name.to_string(),
345                        reason: "TypeScript project names must contain only lowercase letters, digits, and hyphens"
346                            .to_string(),
347                    });
348                }
349            }
350            TargetLanguage::Rust => {
351                // Rust: snake_case, alphanumeric + underscores, no leading digit
352                if !project_name
353                    .chars()
354                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
355                {
356                    bail!(InitError::InvalidProjectName {
357                        name: project_name.to_string(),
358                        reason: "Rust project names must contain only lowercase letters, digits, and underscores"
359                            .to_string(),
360                    });
361                }
362                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
363                    bail!(InitError::InvalidProjectName {
364                        name: project_name.to_string(),
365                        reason: "Rust project names cannot start with a digit".to_string(),
366                    });
367                }
368            }
369            TargetLanguage::Ruby => {
370                // Ruby: snake_case, alphanumeric + underscores, no leading digit
371                if !project_name
372                    .chars()
373                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
374                {
375                    bail!(InitError::InvalidProjectName {
376                        name: project_name.to_string(),
377                        reason: "Ruby project names must contain only lowercase letters, digits, and underscores"
378                            .to_string(),
379                    });
380                }
381                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
382                    bail!(InitError::InvalidProjectName {
383                        name: project_name.to_string(),
384                        reason: "Ruby project names cannot start with a digit".to_string(),
385                    });
386                }
387            }
388            TargetLanguage::Php => {
389                // PHP: alphanumeric + underscores, no leading digit
390                if !project_name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
391                    bail!(InitError::InvalidProjectName {
392                        name: project_name.to_string(),
393                        reason: "PHP project names must contain only alphanumeric characters and underscores"
394                            .to_string(),
395                    });
396                }
397                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
398                    bail!(InitError::InvalidProjectName {
399                        name: project_name.to_string(),
400                        reason: "PHP project names cannot start with a digit".to_string(),
401                    });
402                }
403            }
404            TargetLanguage::Elixir => {
405                if !project_name
406                    .chars()
407                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
408                {
409                    bail!(InitError::InvalidProjectName {
410                        name: project_name.to_string(),
411                        reason: "Elixir project names must contain only lowercase letters, digits, and underscores"
412                            .to_string(),
413                    });
414                }
415                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
416                    bail!(InitError::InvalidProjectName {
417                        name: project_name.to_string(),
418                        reason: "Elixir project names cannot start with a digit".to_string(),
419                    });
420                }
421            }
422        }
423
424        Ok(())
425    }
426
427    /// Create project directory and write all scaffolded files to disk.
428    ///
429    /// This is an internal helper method that will be used once language-specific
430    /// scaffolders are implemented.
431    ///
432    /// # Arguments
433    ///
434    /// - `project_dir`: The root directory to create
435    /// - `files`: The scaffolded files to write
436    ///
437    /// # Returns
438    ///
439    /// Returns a vector of absolute paths to the created files on success.
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if directory creation or file writing fails.
444    #[allow(dead_code)]
445    fn write_files(project_dir: &std::path::Path, files: Vec<ScaffoldedFile>) -> Result<Vec<PathBuf>> {
446        std::fs::create_dir_all(project_dir).context("Failed to create project directory")?;
447
448        let mut created_files = Vec::new();
449
450        for file in files {
451            let full_path = project_dir.join(&file.path);
452
453            // Create parent directories if needed
454            if let Some(parent) = full_path.parent()
455                && !parent.exists()
456            {
457                std::fs::create_dir_all(parent).context(format!("Failed to create directory: {}", parent.display()))?;
458            }
459
460            // Write file
461            std::fs::write(&full_path, &file.content)
462                .context(format!("Failed to write file: {}", full_path.display()))?;
463
464            created_files.push(full_path);
465        }
466
467        Ok(created_files)
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn test_validate_python_project_name_valid() {
477        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Python).is_ok());
478        assert!(InitEngine::validate_project_name("api_v2", TargetLanguage::Python).is_ok());
479        assert!(InitEngine::validate_project_name("a", TargetLanguage::Python).is_ok());
480    }
481
482    #[test]
483    fn test_validate_python_project_name_invalid() {
484        // Uppercase not allowed
485        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::Python).is_err());
486        // Cannot start with digit
487        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Python).is_err());
488        // Hyphens not allowed in Python
489        assert!(InitEngine::validate_project_name("my-api", TargetLanguage::Python).is_err());
490        // Empty name
491        assert!(InitEngine::validate_project_name("", TargetLanguage::Python).is_err());
492    }
493
494    #[test]
495    fn test_validate_typescript_project_name_valid() {
496        assert!(InitEngine::validate_project_name("my-api", TargetLanguage::TypeScript).is_ok());
497        assert!(InitEngine::validate_project_name("api", TargetLanguage::TypeScript).is_ok());
498    }
499
500    #[test]
501    fn test_validate_typescript_project_name_invalid() {
502        // Uppercase not allowed
503        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::TypeScript).is_err());
504        // Underscores not allowed in npm package names
505        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::TypeScript).is_err());
506    }
507
508    #[test]
509    fn test_validate_rust_project_name_valid() {
510        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Rust).is_ok());
511        assert!(InitEngine::validate_project_name("api", TargetLanguage::Rust).is_ok());
512    }
513
514    #[test]
515    fn test_validate_rust_project_name_invalid() {
516        // Uppercase not allowed
517        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::Rust).is_err());
518        // Cannot start with digit
519        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Rust).is_err());
520        // Hyphens not allowed in Rust
521        assert!(InitEngine::validate_project_name("my-api", TargetLanguage::Rust).is_err());
522    }
523
524    #[test]
525    fn test_validate_ruby_project_name_valid() {
526        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Ruby).is_ok());
527    }
528
529    #[test]
530    fn test_validate_ruby_project_name_invalid() {
531        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Ruby).is_err());
532    }
533
534    #[test]
535    fn test_validate_php_project_name_valid() {
536        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Php).is_ok());
537        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::Php).is_ok());
538    }
539
540    #[test]
541    fn test_validate_php_project_name_invalid() {
542        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Php).is_err());
543    }
544
545    #[test]
546    fn test_validate_elixir_project_name_valid() {
547        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Elixir).is_ok());
548    }
549
550    #[test]
551    fn test_validate_elixir_project_name_invalid() {
552        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::Elixir).is_err());
553        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Elixir).is_err());
554    }
555}