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}