vika_cli/specs/
runner.rs

1use crate::config::model::{Config, SpecEntry};
2use crate::error::Result;
3use crate::formatter::FormatterManager;
4use crate::generator::api_client::generate_api_client_with_registry_and_engine_and_spec;
5use crate::generator::module_selector::select_modules;
6use crate::generator::swagger_parser::filter_common_schemas;
7use crate::generator::ts_typings::generate_typings_with_registry_and_engine_and_spec;
8use crate::generator::writer::write_api_client_with_options;
9use crate::generator::zod_schema::generate_zod_schemas_with_registry_and_engine_and_spec;
10use crate::progress::ProgressReporter;
11use std::path::PathBuf;
12
13/// Statistics for a single spec generation run
14#[derive(Debug, Clone)]
15pub struct GenerationStats {
16    pub spec_name: String,
17    pub modules_generated: usize,
18    pub files_generated: usize,
19    pub modules: Vec<String>,
20}
21
22/// Hook generator type
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum HookType {
25    ReactQuery,
26    Swr,
27}
28
29/// Options for generation
30pub struct GenerateOptions {
31    pub use_cache: bool,
32    pub use_backup: bool,
33    pub use_force: bool,
34    pub verbose: bool,
35    pub hook_type: Option<HookType>,
36}
37
38/// Generate code for a single spec
39pub async fn run_single_spec(
40    spec: &SpecEntry,
41    _config: &Config,
42    options: &GenerateOptions,
43) -> Result<GenerationStats> {
44    let mut progress = ProgressReporter::new(options.verbose);
45    // Always use spec name (even for single spec)
46    let spec_name = Some(spec.name.as_str());
47
48    progress.start_spinner(&format!("Fetching spec from: {}", spec.path));
49    let parsed = crate::generator::swagger_parser::fetch_and_parse_spec_with_cache_and_name(
50        &spec.path,
51        options.use_cache,
52        Some(&spec.name),
53    )
54    .await?;
55    progress.finish_spinner(&format!(
56        "Parsed spec with {} modules",
57        parsed.modules.len()
58    ));
59
60    // Use spec-specific configs (required per spec)
61    let schemas_config = &spec.schemas;
62    let apis_config = &spec.apis;
63    let modules_config = &spec.modules;
64
65    // Filter out ignored modules (using spec-specific or global)
66    let available_modules: Vec<String> = parsed
67        .modules
68        .iter()
69        .filter(|m| !modules_config.ignore.contains(m))
70        .cloned()
71        .collect();
72
73    if available_modules.is_empty() {
74        return Err(crate::error::GenerationError::NoModulesAvailable.into());
75    }
76
77    // Use pre-selected modules from config if available, otherwise prompt interactively
78    let selected_modules = if !modules_config.selected.is_empty() {
79        // Validate that all selected modules are available
80        let valid_selected: Vec<String> = modules_config
81            .selected
82            .iter()
83            .filter(|m| available_modules.contains(m))
84            .cloned()
85            .collect();
86
87        if valid_selected.is_empty() {
88            return Err(crate::error::GenerationError::NoModulesSelected.into());
89        }
90
91        valid_selected
92    } else {
93        // Select modules interactively (using spec-specific or global ignore list)
94        select_modules(&available_modules, &modules_config.ignore)?
95    };
96
97    // Filter common schemas based on selected modules only
98    let (filtered_module_schemas, common_schemas) =
99        filter_common_schemas(&parsed.module_schemas, &selected_modules);
100
101    // Generate code for each module (using spec-specific or global output directories)
102    let schemas_dir = PathBuf::from(&schemas_config.output);
103    let apis_dir = PathBuf::from(&apis_config.output);
104
105    // Ensure http.ts file exists (only once per output directory)
106    let http_file = apis_dir.join("http.ts");
107    if !http_file.exists() {
108        use crate::generator::writer::write_http_client_template;
109        write_http_client_template(&http_file)?;
110        if options.verbose {
111            progress.success(&format!("Created {}", http_file.display()));
112        }
113    }
114
115    let mut total_files = 0;
116
117    // Generate common module first if there are shared schemas
118    if !common_schemas.is_empty() {
119        progress.start_spinner("Generating common schemas...");
120
121        // Shared enum registry to ensure consistent naming between TypeScript and Zod
122        let mut shared_enum_registry = std::collections::HashMap::new();
123
124        // Initialize template engine
125        let project_root = std::env::current_dir().ok();
126        let template_engine =
127            crate::templates::engine::TemplateEngine::new(project_root.as_deref())?;
128
129        // Generate TypeScript typings for common schemas
130        // Pass empty common_schemas list so common schemas don't prefix themselves with "Common."
131        let common_types = generate_typings_with_registry_and_engine_and_spec(
132            &parsed.openapi,
133            &parsed.schemas,
134            &common_schemas,
135            &mut shared_enum_registry,
136            &[], // Empty list - common schemas shouldn't prefix themselves
137            Some(&template_engine),
138            spec_name,
139        )?;
140
141        // Generate Zod schemas for common schemas (using same registry)
142        // Pass empty common_schemas list so common schemas don't prefix themselves with "Common."
143        let common_zod_schemas = generate_zod_schemas_with_registry_and_engine_and_spec(
144            &parsed.openapi,
145            &parsed.schemas,
146            &common_schemas,
147            &mut shared_enum_registry,
148            &[], // Empty list - common schemas shouldn't prefix themselves
149            Some(&template_engine),
150            spec_name,
151        )?;
152
153        // Write common schemas
154        use crate::generator::writer::write_schemas_with_module_mapping;
155        let common_files = write_schemas_with_module_mapping(
156            &schemas_dir,
157            "common",
158            &common_types,
159            &common_zod_schemas,
160            spec_name,
161            options.use_backup,
162            options.use_force,
163            Some(&filtered_module_schemas),
164            &common_schemas,
165        )?;
166        total_files += common_files.len();
167        progress.finish_spinner(&format!(
168            "Generated {} common schema files",
169            common_files.len()
170        ));
171    }
172
173    for module in &selected_modules {
174        progress.start_spinner(&format!("Generating code for module: {}", module));
175
176        // Get operations for this module
177        let operations = parsed
178            .operations_by_tag
179            .get(module)
180            .cloned()
181            .unwrap_or_default();
182
183        if operations.is_empty() {
184            progress.warning(&format!("No operations found for module: {}", module));
185            continue;
186        }
187
188        // Get schema names used by this module (from filtered schemas)
189        let module_schema_names = filtered_module_schemas
190            .get(module)
191            .cloned()
192            .unwrap_or_default();
193
194        // Initialize template engine
195        let project_root = std::env::current_dir().ok();
196        let template_engine =
197            crate::templates::engine::TemplateEngine::new(project_root.as_deref())?;
198
199        // Shared enum registry to ensure consistent naming between TypeScript and Zod
200        let mut shared_enum_registry = std::collections::HashMap::new();
201
202        // Generate TypeScript typings
203        let types = if !module_schema_names.is_empty() {
204            generate_typings_with_registry_and_engine_and_spec(
205                &parsed.openapi,
206                &parsed.schemas,
207                &module_schema_names,
208                &mut shared_enum_registry,
209                &common_schemas,
210                Some(&template_engine),
211                spec_name,
212            )?
213        } else {
214            Vec::new()
215        };
216
217        // Generate Zod schemas (using same registry)
218        let zod_schemas = if !module_schema_names.is_empty() {
219            generate_zod_schemas_with_registry_and_engine_and_spec(
220                &parsed.openapi,
221                &parsed.schemas,
222                &module_schema_names,
223                &mut shared_enum_registry,
224                &common_schemas,
225                Some(&template_engine),
226                spec_name,
227            )?
228        } else {
229            Vec::new()
230        };
231
232        // Generate API client (using same enum registry as schemas)
233        let api_result = generate_api_client_with_registry_and_engine_and_spec(
234            &parsed.openapi,
235            &operations,
236            module,
237            &common_schemas,
238            &mut shared_enum_registry,
239            Some(&template_engine),
240            spec_name,
241        )?;
242
243        // Combine response types with schema types
244        let mut all_types = types;
245        all_types.extend(api_result.response_types);
246
247        // Write schemas (with backup and conflict detection)
248        // Pass module_schemas mapping to enable cross-module enum imports
249        use crate::generator::writer::write_schemas_with_module_mapping;
250        let schema_files = write_schemas_with_module_mapping(
251            &schemas_dir,
252            module,
253            &all_types,
254            &zod_schemas,
255            spec_name,
256            options.use_backup,
257            options.use_force,
258            Some(&filtered_module_schemas),
259            &common_schemas,
260        )?;
261        total_files += schema_files.len();
262
263        // Write API client (with backup and conflict detection)
264        let api_files = write_api_client_with_options(
265            &apis_dir,
266            module,
267            &api_result.functions,
268            spec_name,
269            options.use_backup,
270            options.use_force,
271        )?;
272        total_files += api_files.len();
273
274        // Generate hooks if requested
275        if let Some(hook_type) = options.hook_type {
276            progress.start_spinner(&format!("Generating hooks for module: {}", module));
277
278            // Generate query keys first (hooks depend on them)
279            use crate::generator::query_keys::generate_query_keys;
280            let query_keys_context = generate_query_keys(&operations, module, spec_name);
281
282            // Render query keys template
283            let query_keys_content = template_engine.render(
284                crate::templates::registry::TemplateId::QueryKeys,
285                &query_keys_context,
286            )?;
287
288            // Write query keys file
289            // Default output directory: src/query-keys/{spec}/{module}.ts
290            let root_dir = std::env::current_dir().ok();
291            let query_keys_output = if let Some(ref root) = root_dir {
292                if let Some(spec) = spec_name {
293                    root.join("src").join("query-keys").join(spec)
294                } else {
295                    root.join("src").join("query-keys")
296                }
297            } else {
298                PathBuf::from("src/query-keys")
299            };
300
301            use crate::generator::writer::write_query_keys_with_options;
302            write_query_keys_with_options(
303                &query_keys_output,
304                module,
305                &query_keys_content,
306                spec_name,
307                options.use_backup,
308                options.use_force,
309            )?;
310            total_files += 1;
311
312            // Generate hooks based on type
313            let hooks = match hook_type {
314                HookType::ReactQuery => {
315                    use crate::generator::hooks::react_query::generate_react_query_hooks;
316                    generate_react_query_hooks(
317                        &parsed.openapi,
318                        &operations,
319                        module,
320                        spec_name,
321                        &common_schemas,
322                        &mut shared_enum_registry,
323                        &template_engine,
324                    )?
325                }
326                HookType::Swr => {
327                    use crate::generator::hooks::swr::generate_swr_hooks;
328                    generate_swr_hooks(
329                        &parsed.openapi,
330                        &operations,
331                        module,
332                        spec_name,
333                        &common_schemas,
334                        &mut shared_enum_registry,
335                        &template_engine,
336                    )?
337                }
338            };
339
340            // Write hooks files
341            // Default output directory: src/hooks/{spec}/{module}/
342            let hooks_output = if let Some(ref root) = root_dir {
343                if let Some(spec) = spec_name {
344                    root.join("src").join("hooks").join(spec)
345                } else {
346                    root.join("src").join("hooks")
347                }
348            } else {
349                PathBuf::from("src/hooks")
350            };
351
352            use crate::generator::writer::write_hooks_with_options;
353            let hook_files = write_hooks_with_options(
354                &hooks_output,
355                module,
356                &hooks,
357                spec_name,
358                options.use_backup,
359                options.use_force,
360            )?;
361            total_files += hook_files.len();
362
363            progress.finish_spinner(&format!(
364                "Generated {} hook files for module: {}",
365                hook_files.len(),
366                module
367            ));
368        }
369
370        progress.finish_spinner(&format!(
371            "Generated {} files for module: {}",
372            schema_files.len() + api_files.len(),
373            module
374        ));
375    }
376
377    // Format all generated files with prettier/biome if available
378    let mut all_generated_files = Vec::new();
379
380    // Collect schema files recursively
381    if schemas_dir.exists() {
382        collect_ts_files(&schemas_dir, &mut all_generated_files)?;
383    }
384
385    // Collect API files recursively
386    if apis_dir.exists() {
387        collect_ts_files(&apis_dir, &mut all_generated_files)?;
388    }
389
390    // Collect hook files recursively if hooks were generated
391    if options.hook_type.is_some() {
392        let root_dir = std::env::current_dir().ok();
393        let hooks_dir = if let Some(ref root) = root_dir {
394            if let Some(spec) = spec_name {
395                root.join("src").join("hooks").join(spec)
396            } else {
397                root.join("src").join("hooks")
398            }
399        } else {
400            PathBuf::from("src/hooks")
401        };
402        if hooks_dir.exists() {
403            collect_ts_files(&hooks_dir, &mut all_generated_files)?;
404        }
405
406        // Collect query keys files
407        let query_keys_dir = if let Some(ref root) = root_dir {
408            if let Some(spec) = spec_name {
409                root.join("src").join("query-keys").join(spec)
410            } else {
411                root.join("src").join("query-keys")
412            }
413        } else {
414            PathBuf::from("src/query-keys")
415        };
416        if query_keys_dir.exists() {
417            collect_ts_files(&query_keys_dir, &mut all_generated_files)?;
418        }
419    }
420
421    // Format files if formatter is available
422    if !all_generated_files.is_empty() {
423        // Get current directory to resolve relative paths
424        let current_dir =
425            std::env::current_dir().map_err(|e| crate::error::FileSystemError::ReadFileFailed {
426                path: ".".to_string(),
427                source: e,
428            })?;
429
430        // Resolve to absolute paths
431        let schemas_dir_abs = if schemas_dir.is_absolute() {
432            schemas_dir.clone()
433        } else {
434            current_dir.join(&schemas_dir)
435        };
436        let apis_dir_abs = if apis_dir.is_absolute() {
437            apis_dir.clone()
438        } else {
439            current_dir.join(&apis_dir)
440        };
441
442        let output_base = schemas_dir_abs
443            .parent()
444            .and_then(|p| p.parent())
445            .or_else(|| apis_dir_abs.parent().and_then(|p| p.parent()));
446
447        let formatter = if let Some(base_dir) = output_base {
448            FormatterManager::detect_formatter_from_dir(base_dir)
449                .or_else(FormatterManager::detect_formatter)
450        } else {
451            FormatterManager::detect_formatter()
452        };
453
454        if let Some(formatter) = formatter {
455            progress.start_spinner("Formatting generated files...");
456            let original_dir = std::env::current_dir().map_err(|e| {
457                crate::error::FileSystemError::ReadFileFailed {
458                    path: ".".to_string(),
459                    source: e,
460                }
461            })?;
462
463            if let Some(output_base) = output_base {
464                // Ensure output_base is not empty
465                if output_base.as_os_str().is_empty() {
466                    // Fallback: use current directory
467                    FormatterManager::format_files(&all_generated_files, formatter)?;
468                } else {
469                    std::env::set_current_dir(output_base).map_err(|e| {
470                        crate::error::FileSystemError::ReadFileFailed {
471                            path: output_base.display().to_string(),
472                            source: e,
473                        }
474                    })?;
475
476                    let relative_files: Vec<PathBuf> = all_generated_files
477                        .iter()
478                        .filter_map(|p| {
479                            p.strip_prefix(output_base)
480                                .ok()
481                                .map(|p| p.to_path_buf())
482                                .filter(|p| !p.as_os_str().is_empty())
483                        })
484                        .collect();
485
486                    if !relative_files.is_empty() {
487                        let result = FormatterManager::format_files(&relative_files, formatter);
488
489                        std::env::set_current_dir(&original_dir).map_err(|e| {
490                            crate::error::FileSystemError::ReadFileFailed {
491                                path: original_dir.display().to_string(),
492                                source: e,
493                            }
494                        })?;
495
496                        result?;
497
498                        // Update metadata for formatted files to reflect formatted content hash (batch update)
499                        use crate::generator::writer::batch_update_file_metadata_from_disk;
500                        if let Err(e) = batch_update_file_metadata_from_disk(&all_generated_files) {
501                            // Log but don't fail - metadata update is best effort
502                            progress.warning(&format!("Failed to update metadata: {}", e));
503                        }
504                    } else {
505                        std::env::set_current_dir(&original_dir).map_err(|e| {
506                            crate::error::FileSystemError::ReadFileFailed {
507                                path: original_dir.display().to_string(),
508                                source: e,
509                            }
510                        })?;
511                    }
512                }
513            } else {
514                FormatterManager::format_files(&all_generated_files, formatter)?;
515
516                // Update metadata for formatted files to reflect formatted content hash (batch update)
517                use crate::generator::writer::batch_update_file_metadata_from_disk;
518                if let Err(e) = batch_update_file_metadata_from_disk(&all_generated_files) {
519                    // Log but don't fail - metadata update is best effort
520                    progress.warning(&format!("Failed to update metadata: {}", e));
521                }
522            }
523            progress.finish_spinner("Files formatted");
524        }
525    }
526
527    Ok(GenerationStats {
528        spec_name: spec.name.clone(),
529        modules_generated: selected_modules.len(),
530        files_generated: total_files,
531        modules: selected_modules,
532    })
533}
534
535/// Generate code for multiple specs sequentially
536pub async fn run_all_specs(
537    specs: &[SpecEntry],
538    config: &Config,
539    options: &GenerateOptions,
540) -> Result<Vec<GenerationStats>> {
541    let mut stats = Vec::new();
542    for spec in specs {
543        let result = run_single_spec(spec, config, options).await?;
544        stats.push(result);
545    }
546    Ok(stats)
547}
548
549fn collect_ts_files(dir: &std::path::Path, files: &mut Vec<PathBuf>) -> Result<()> {
550    if dir.is_dir() {
551        for entry in
552            std::fs::read_dir(dir).map_err(|e| crate::error::FileSystemError::ReadFileFailed {
553                path: dir.display().to_string(),
554                source: e,
555            })?
556        {
557            let entry = entry.map_err(|e| crate::error::FileSystemError::ReadFileFailed {
558                path: dir.display().to_string(),
559                source: e,
560            })?;
561            let path = entry.path();
562            // Skip if path is empty or invalid
563            if path.as_os_str().is_empty() {
564                continue;
565            }
566            if path.is_dir() {
567                collect_ts_files(&path, files)?;
568            } else if path.extension().and_then(|s| s.to_str()) == Some("ts") {
569                files.push(path);
570            }
571        }
572    }
573    Ok(())
574}