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}