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