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}