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        Self::validate_request(&request).context("Project initialization request validation failed")?;
194
195        let scaffolder = Self::get_scaffolder(request.language);
196
197        let files = scaffolder
198            .scaffold(&request.project_dir, &request.project_name)
199            .context("Failed to scaffold project files")?;
200
201        std::fs::create_dir_all(&request.project_dir).context(format!(
202            "Failed to create project directory: {}",
203            request.project_dir.display()
204        ))?;
205
206        let mut files_created = Vec::new();
207        for file in files {
208            let full_path = request.project_dir.join(&file.path);
209
210            if let Some(parent) = full_path.parent() {
211                std::fs::create_dir_all(parent).context(format!("Failed to create directory: {}", parent.display()))?;
212            }
213
214            std::fs::write(&full_path, &file.content)
215                .context(format!("Failed to write file: {}", full_path.display()))?;
216
217            files_created.push(full_path);
218        }
219
220        let next_steps = scaffolder.next_steps(&request.project_name);
221
222        Ok(InitResponse {
223            files_created,
224            next_steps,
225        })
226    }
227
228    /// Get the appropriate scaffolder for a language
229    fn get_scaffolder(language: TargetLanguage) -> Box<dyn super::scaffolder::ProjectScaffolder> {
230        match language {
231            TargetLanguage::Python => Box::new(super::python::PythonScaffolder),
232            TargetLanguage::TypeScript => Box::new(super::typescript::TypeScriptScaffolder),
233            TargetLanguage::Rust => Box::new(super::rust_lang::RustScaffolder),
234            TargetLanguage::Ruby => Box::new(super::ruby::RubyScaffolder),
235            TargetLanguage::Php => Box::new(super::php::PhpScaffolder),
236            TargetLanguage::Elixir => Box::new(super::elixir::ElixirScaffolder),
237        }
238    }
239
240    /// Validate the initialization request.
241    ///
242    /// This method checks:
243    ///
244    /// - Project name is valid for the target language
245    /// - Project directory doesn't already exist
246    /// - Schema path (if provided) exists and is accessible
247    ///
248    /// # Arguments
249    ///
250    /// - `request`: The `InitRequest` to validate
251    ///
252    /// # Returns
253    ///
254    /// Returns `Ok(())` if all validations pass, otherwise returns an appropriate error.
255    ///
256    /// # Errors
257    ///
258    /// Returns validation errors with context about what failed.
259    fn validate_request(request: &InitRequest) -> Result<()> {
260        Self::validate_project_name(&request.project_name, request.language)
261            .context("Project name validation failed")?;
262
263        if request.project_dir.exists() {
264            bail!(InitError::DirectoryAlreadyExists {
265                path: request.project_dir.clone(),
266            });
267        }
268
269        if let Some(schema_path) = &request.schema_path
270            && !schema_path.exists()
271        {
272            bail!(InitError::SchemaPathNotFound {
273                path: schema_path.clone(),
274            });
275        }
276
277        Ok(())
278    }
279
280    /// Validate that a project name is appropriate for the target language.
281    ///
282    /// Naming rules vary by language:
283    ///
284    /// - **Python**: Lowercase, alphanumeric + underscore, no leading digit
285    /// - **TypeScript**: Must be valid npm package name (lowercase, hyphen OK)
286    /// - **Ruby**: `Snake_case`, no leading digit
287    /// - **Rust**: `Snake_case`, alphanumeric + underscore, no leading digit
288    /// - **PHP**: Alphanumeric + underscore, no leading digit
289    ///
290    /// # Arguments
291    ///
292    /// - `project_name`: The name to validate
293    /// - `language`: The target language whose rules apply
294    ///
295    /// # Returns
296    ///
297    /// Returns `Ok(())` if the name is valid, otherwise returns a descriptive error.
298    pub fn validate_project_name(project_name: &str, language: TargetLanguage) -> Result<()> {
299        if project_name.is_empty() {
300            bail!(InitError::InvalidProjectName {
301                name: project_name.to_string(),
302                reason: "Project name cannot be empty".to_string(),
303            });
304        }
305
306        match language {
307            TargetLanguage::Python => {
308                if !project_name
309                    .chars()
310                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
311                {
312                    bail!(InitError::InvalidProjectName {
313                        name: project_name.to_string(),
314                        reason: "Python project names must contain only lowercase letters, digits, and underscores"
315                            .to_string(),
316                    });
317                }
318                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
319                    bail!(InitError::InvalidProjectName {
320                        name: project_name.to_string(),
321                        reason: "Python project names cannot start with a digit".to_string(),
322                    });
323                }
324            }
325            TargetLanguage::TypeScript => {
326                if !project_name
327                    .chars()
328                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
329                {
330                    bail!(InitError::InvalidProjectName {
331                        name: project_name.to_string(),
332                        reason: "TypeScript project names must contain only lowercase letters, digits, and hyphens"
333                            .to_string(),
334                    });
335                }
336            }
337            TargetLanguage::Rust => {
338                if !project_name
339                    .chars()
340                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
341                {
342                    bail!(InitError::InvalidProjectName {
343                        name: project_name.to_string(),
344                        reason: "Rust project names must contain only lowercase letters, digits, and underscores"
345                            .to_string(),
346                    });
347                }
348                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
349                    bail!(InitError::InvalidProjectName {
350                        name: project_name.to_string(),
351                        reason: "Rust project names cannot start with a digit".to_string(),
352                    });
353                }
354            }
355            TargetLanguage::Ruby => {
356                if !project_name
357                    .chars()
358                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
359                {
360                    bail!(InitError::InvalidProjectName {
361                        name: project_name.to_string(),
362                        reason: "Ruby project names must contain only lowercase letters, digits, and underscores"
363                            .to_string(),
364                    });
365                }
366                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
367                    bail!(InitError::InvalidProjectName {
368                        name: project_name.to_string(),
369                        reason: "Ruby project names cannot start with a digit".to_string(),
370                    });
371                }
372            }
373            TargetLanguage::Php => {
374                if !project_name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
375                    bail!(InitError::InvalidProjectName {
376                        name: project_name.to_string(),
377                        reason: "PHP project names must contain only alphanumeric characters 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: "PHP project names cannot start with a digit".to_string(),
385                    });
386                }
387            }
388            TargetLanguage::Elixir => {
389                if !project_name
390                    .chars()
391                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
392                {
393                    bail!(InitError::InvalidProjectName {
394                        name: project_name.to_string(),
395                        reason: "Elixir project names must contain only lowercase letters, digits, and underscores"
396                            .to_string(),
397                    });
398                }
399                if project_name.starts_with(|c: char| c.is_ascii_digit()) {
400                    bail!(InitError::InvalidProjectName {
401                        name: project_name.to_string(),
402                        reason: "Elixir project names cannot start with a digit".to_string(),
403                    });
404                }
405            }
406        }
407
408        Ok(())
409    }
410
411    /// Create project directory and write all scaffolded files to disk.
412    ///
413    /// This is an internal helper method that will be used once language-specific
414    /// scaffolders are implemented.
415    ///
416    /// # Arguments
417    ///
418    /// - `project_dir`: The root directory to create
419    /// - `files`: The scaffolded files to write
420    ///
421    /// # Returns
422    ///
423    /// Returns a vector of absolute paths to the created files on success.
424    ///
425    /// # Errors
426    ///
427    /// Returns an error if directory creation or file writing fails.
428    #[allow(dead_code)]
429    fn write_files(project_dir: &std::path::Path, files: Vec<ScaffoldedFile>) -> Result<Vec<PathBuf>> {
430        std::fs::create_dir_all(project_dir).context("Failed to create project directory")?;
431
432        let mut created_files = Vec::new();
433
434        for file in files {
435            let full_path = project_dir.join(&file.path);
436
437            if let Some(parent) = full_path.parent()
438                && !parent.exists()
439            {
440                std::fs::create_dir_all(parent).context(format!("Failed to create directory: {}", parent.display()))?;
441            }
442
443            std::fs::write(&full_path, &file.content)
444                .context(format!("Failed to write file: {}", full_path.display()))?;
445
446            created_files.push(full_path);
447        }
448
449        Ok(created_files)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_validate_python_project_name_valid() {
459        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Python).is_ok());
460        assert!(InitEngine::validate_project_name("api_v2", TargetLanguage::Python).is_ok());
461        assert!(InitEngine::validate_project_name("a", TargetLanguage::Python).is_ok());
462    }
463
464    #[test]
465    fn test_validate_python_project_name_invalid() {
466        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::Python).is_err());
467        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Python).is_err());
468        assert!(InitEngine::validate_project_name("my-api", TargetLanguage::Python).is_err());
469        assert!(InitEngine::validate_project_name("", TargetLanguage::Python).is_err());
470    }
471
472    #[test]
473    fn test_validate_typescript_project_name_valid() {
474        assert!(InitEngine::validate_project_name("my-api", TargetLanguage::TypeScript).is_ok());
475        assert!(InitEngine::validate_project_name("api", TargetLanguage::TypeScript).is_ok());
476    }
477
478    #[test]
479    fn test_validate_typescript_project_name_invalid() {
480        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::TypeScript).is_err());
481        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::TypeScript).is_err());
482    }
483
484    #[test]
485    fn test_validate_rust_project_name_valid() {
486        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Rust).is_ok());
487        assert!(InitEngine::validate_project_name("api", TargetLanguage::Rust).is_ok());
488    }
489
490    #[test]
491    fn test_validate_rust_project_name_invalid() {
492        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::Rust).is_err());
493        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Rust).is_err());
494        assert!(InitEngine::validate_project_name("my-api", TargetLanguage::Rust).is_err());
495    }
496
497    #[test]
498    fn test_validate_ruby_project_name_valid() {
499        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Ruby).is_ok());
500    }
501
502    #[test]
503    fn test_validate_ruby_project_name_invalid() {
504        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Ruby).is_err());
505    }
506
507    #[test]
508    fn test_validate_php_project_name_valid() {
509        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Php).is_ok());
510        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::Php).is_ok());
511    }
512
513    #[test]
514    fn test_validate_php_project_name_invalid() {
515        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Php).is_err());
516    }
517
518    #[test]
519    fn test_validate_elixir_project_name_valid() {
520        assert!(InitEngine::validate_project_name("my_api", TargetLanguage::Elixir).is_ok());
521    }
522
523    #[test]
524    fn test_validate_elixir_project_name_invalid() {
525        assert!(InitEngine::validate_project_name("MyApi", TargetLanguage::Elixir).is_err());
526        assert!(InitEngine::validate_project_name("2api", TargetLanguage::Elixir).is_err());
527    }
528}