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    let _root_dir = PathBuf::from(&config.root_dir);
105
106    // Get hooks config (use defaults if not specified)
107    let hooks_config = spec.hooks.clone().unwrap_or_default();
108
109    // Runtime client is generated once at root_dir level (handled in generate.rs)
110
111    let mut total_files = 0;
112
113    // Generate common module first if there are shared schemas
114    if !common_schemas.is_empty() {
115        progress.start_spinner("Generating common schemas...");
116
117        // Shared enum registry to ensure consistent naming between TypeScript and Zod
118        let mut shared_enum_registry = std::collections::HashMap::new();
119
120        // Initialize template engine
121        let project_root = std::env::current_dir().ok();
122        let template_engine =
123            crate::templates::engine::TemplateEngine::new(project_root.as_deref())?;
124
125        // Generate TypeScript typings for common schemas
126        // Pass empty common_schemas list so common schemas don't prefix themselves with "Common."
127        let common_types = generate_typings_with_registry_and_engine_and_spec(
128            &parsed.openapi,
129            &parsed.schemas,
130            &common_schemas,
131            &mut shared_enum_registry,
132            &[], // Empty list - common schemas shouldn't prefix themselves
133            Some(&template_engine),
134            spec_name,
135        )?;
136
137        // Generate Zod schemas for common schemas (using same registry)
138        // Pass empty common_schemas list so common schemas don't prefix themselves with "Common."
139        let common_zod_schemas = generate_zod_schemas_with_registry_and_engine_and_spec(
140            &parsed.openapi,
141            &parsed.schemas,
142            &common_schemas,
143            &mut shared_enum_registry,
144            &[], // Empty list - common schemas shouldn't prefix themselves
145            Some(&template_engine),
146            spec_name,
147        )?;
148
149        // Write common schemas
150        use crate::generator::writer::write_schemas_with_module_mapping;
151        let common_files = write_schemas_with_module_mapping(
152            &schemas_dir,
153            "common",
154            &common_types,
155            &common_zod_schemas,
156            spec_name,
157            options.use_backup,
158            options.use_force,
159            Some(&filtered_module_schemas),
160            &common_schemas,
161        )?;
162        total_files += common_files.len();
163        progress.finish_spinner(&format!(
164            "Generated {} common schema files",
165            common_files.len()
166        ));
167    }
168
169    for module in &selected_modules {
170        progress.start_spinner(&format!("Generating code for module: {}", module));
171
172        // Get operations for this module
173        let operations = parsed
174            .operations_by_tag
175            .get(module)
176            .cloned()
177            .unwrap_or_default();
178
179        if operations.is_empty() {
180            progress.warning(&format!("No operations found for module: {}", module));
181            continue;
182        }
183
184        // Get schema names used by this module (from filtered schemas)
185        let module_schema_names = filtered_module_schemas
186            .get(module)
187            .cloned()
188            .unwrap_or_default();
189
190        // Initialize template engine
191        let project_root = std::env::current_dir().ok();
192        let template_engine =
193            crate::templates::engine::TemplateEngine::new(project_root.as_deref())?;
194
195        // Shared enum registry to ensure consistent naming between TypeScript and Zod
196        let mut shared_enum_registry = std::collections::HashMap::new();
197
198        // Generate TypeScript typings
199        let types = if !module_schema_names.is_empty() {
200            generate_typings_with_registry_and_engine_and_spec(
201                &parsed.openapi,
202                &parsed.schemas,
203                &module_schema_names,
204                &mut shared_enum_registry,
205                &common_schemas,
206                Some(&template_engine),
207                spec_name,
208            )?
209        } else {
210            Vec::new()
211        };
212
213        // Generate Zod schemas (using same registry)
214        let zod_schemas = if !module_schema_names.is_empty() {
215            generate_zod_schemas_with_registry_and_engine_and_spec(
216                &parsed.openapi,
217                &parsed.schemas,
218                &module_schema_names,
219                &mut shared_enum_registry,
220                &common_schemas,
221                Some(&template_engine),
222                spec_name,
223            )?
224        } else {
225            Vec::new()
226        };
227
228        // Generate query params types and Zod schemas
229        // Pass existing types and zod schemas to avoid duplicates
230        use crate::generator::query_params::{
231            generate_query_params_for_module, QueryParamsContext,
232        };
233        let query_params_result = generate_query_params_for_module(QueryParamsContext {
234            openapi: &parsed.openapi,
235            operations: &operations,
236            enum_registry: &mut shared_enum_registry,
237            template_engine: Some(&template_engine),
238            spec_name,
239            existing_types: &types,
240            existing_zod_schemas: &zod_schemas,
241        })?;
242
243        // Generate API client (using same enum registry as schemas)
244        let api_result = generate_api_client_with_registry_and_engine_and_spec(
245            &parsed.openapi,
246            &operations,
247            module,
248            &common_schemas,
249            &mut shared_enum_registry,
250            Some(&template_engine),
251            spec_name,
252            Some(&config.root_dir),
253            Some(&apis_config.output),
254            Some(&schemas_config.output),
255        )?;
256
257        // Response types are written to API files, not schema files
258        // Combine schema types with query params types
259        let mut all_types = types;
260        all_types.extend(query_params_result.types);
261
262        // Combine Zod schemas with query params Zod schemas
263        let mut all_zod_schemas = zod_schemas;
264        all_zod_schemas.extend(query_params_result.zod_schemas);
265
266        // Write schemas (with backup and conflict detection)
267        // Pass module_schemas mapping to enable cross-module enum imports
268        use crate::generator::writer::write_schemas_with_module_mapping;
269        let schema_files = write_schemas_with_module_mapping(
270            &schemas_dir,
271            module,
272            &all_types,
273            &all_zod_schemas,
274            spec_name,
275            options.use_backup,
276            options.use_force,
277            Some(&filtered_module_schemas),
278            &common_schemas,
279        )?;
280        total_files += schema_files.len();
281
282        // Write API client (with backup and conflict detection)
283        let api_files = write_api_client_with_options(
284            &apis_dir,
285            module,
286            &api_result.functions,
287            spec_name,
288            options.use_backup,
289            options.use_force,
290        )?;
291        total_files += api_files.len();
292
293        // Determine hook type: options.hook_type (from CLI) takes precedence,
294        // otherwise check spec's hooks.library config
295        let hook_type = options.hook_type.or_else(|| {
296            hooks_config
297                .library
298                .as_ref()
299                .and_then(|lib| match lib.as_str() {
300                    "react-query" => Some(HookType::ReactQuery),
301                    "swr" => Some(HookType::Swr),
302                    _ => None,
303                })
304        });
305
306        // Generate hooks if requested
307        if let Some(hook_type) = hook_type {
308            progress.start_spinner(&format!("Generating hooks for module: {}", module));
309
310            // Generate query keys first (hooks depend on them)
311            use crate::generator::query_keys::generate_query_keys;
312            let query_keys_context = generate_query_keys(&operations, module, spec_name);
313
314            // Render query keys template
315            let query_keys_content = template_engine.render(
316                crate::templates::registry::TemplateId::QueryKeys,
317                &query_keys_context,
318            )?;
319
320            // Write query keys file using configured output directory
321            // Note: output_dir already includes spec_name if needed (from config), just like schemas/apis
322            let query_keys_output = PathBuf::from(&hooks_config.query_keys_output);
323
324            use crate::generator::writer::write_query_keys_with_options;
325            write_query_keys_with_options(
326                &query_keys_output,
327                module,
328                &query_keys_content,
329                spec_name,
330                options.use_backup,
331                options.use_force,
332            )?;
333            total_files += 1;
334
335            // Generate hooks based on type
336            let hooks = match hook_type {
337                HookType::ReactQuery => {
338                    use crate::generator::hooks::react_query::generate_react_query_hooks;
339                    generate_react_query_hooks(
340                        &parsed.openapi,
341                        &operations,
342                        module,
343                        spec_name,
344                        &common_schemas,
345                        &mut shared_enum_registry,
346                        &template_engine,
347                        Some(&apis_config.output),
348                        Some(&schemas_config.output),
349                        Some(&hooks_config.output),
350                        Some(&hooks_config.query_keys_output),
351                    )?
352                }
353                HookType::Swr => {
354                    use crate::generator::hooks::swr::generate_swr_hooks;
355                    generate_swr_hooks(
356                        &parsed.openapi,
357                        &operations,
358                        module,
359                        spec_name,
360                        &common_schemas,
361                        &mut shared_enum_registry,
362                        &template_engine,
363                        Some(&apis_config.output),
364                        Some(&schemas_config.output),
365                        Some(&hooks_config.output),
366                        Some(&hooks_config.query_keys_output),
367                    )?
368                }
369            };
370
371            // Write hooks files using configured output directory
372            // Note: output_dir already includes spec_name if needed (from config), just like schemas/apis
373            let hooks_output = PathBuf::from(&hooks_config.output);
374
375            use crate::generator::writer::write_hooks_with_options;
376            let hook_files = write_hooks_with_options(
377                &hooks_output,
378                module,
379                &hooks,
380                spec_name,
381                options.use_backup,
382                options.use_force,
383            )?;
384            total_files += hook_files.len();
385
386            progress.finish_spinner(&format!(
387                "Generated {} hook files for module: {}",
388                hook_files.len(),
389                module
390            ));
391        }
392
393        progress.finish_spinner(&format!(
394            "Generated {} files for module: {}",
395            schema_files.len() + api_files.len(),
396            module
397        ));
398    }
399
400    // Format all generated files with prettier/biome if available
401    let mut all_generated_files = Vec::new();
402
403    // Collect schema files recursively
404    if schemas_dir.exists() {
405        collect_ts_files(&schemas_dir, &mut all_generated_files)?;
406    }
407
408    // Collect API files recursively
409    if apis_dir.exists() {
410        collect_ts_files(&apis_dir, &mut all_generated_files)?;
411    }
412
413    // Collect hook files recursively if hooks were generated
414    if options.hook_type.is_some() {
415        let root_dir = std::env::current_dir().ok();
416        let hooks_dir = if let Some(ref root) = root_dir {
417            if let Some(spec) = spec_name {
418                root.join("src").join("hooks").join(spec)
419            } else {
420                root.join("src").join("hooks")
421            }
422        } else {
423            PathBuf::from("src/hooks")
424        };
425        if hooks_dir.exists() {
426            collect_ts_files(&hooks_dir, &mut all_generated_files)?;
427        }
428
429        // Collect query keys files
430        let query_keys_dir = if let Some(ref root) = root_dir {
431            if let Some(spec) = spec_name {
432                root.join("src").join("query-keys").join(spec)
433            } else {
434                root.join("src").join("query-keys")
435            }
436        } else {
437            PathBuf::from("src/query-keys")
438        };
439        if query_keys_dir.exists() {
440            collect_ts_files(&query_keys_dir, &mut all_generated_files)?;
441        }
442    }
443
444    // Format files if formatter is available
445    if !all_generated_files.is_empty() {
446        // Get current directory to resolve relative paths
447        let current_dir =
448            std::env::current_dir().map_err(|e| crate::error::FileSystemError::ReadFileFailed {
449                path: ".".to_string(),
450                source: e,
451            })?;
452
453        // Resolve to absolute paths
454        let schemas_dir_abs = if schemas_dir.is_absolute() {
455            schemas_dir.clone()
456        } else {
457            current_dir.join(&schemas_dir)
458        };
459        let apis_dir_abs = if apis_dir.is_absolute() {
460            apis_dir.clone()
461        } else {
462            current_dir.join(&apis_dir)
463        };
464
465        let output_base = schemas_dir_abs
466            .parent()
467            .and_then(|p| p.parent())
468            .or_else(|| apis_dir_abs.parent().and_then(|p| p.parent()));
469
470        let formatter = if let Some(base_dir) = output_base {
471            FormatterManager::detect_formatter_from_dir(base_dir)
472                .or_else(FormatterManager::detect_formatter)
473        } else {
474            FormatterManager::detect_formatter()
475        };
476
477        if let Some(formatter) = formatter {
478            progress.start_spinner("Formatting generated files...");
479            let original_dir = std::env::current_dir().map_err(|e| {
480                crate::error::FileSystemError::ReadFileFailed {
481                    path: ".".to_string(),
482                    source: e,
483                }
484            })?;
485
486            if let Some(output_base) = output_base {
487                // Ensure output_base is not empty
488                if output_base.as_os_str().is_empty() {
489                    // Fallback: use current directory
490                    FormatterManager::format_files(&all_generated_files, formatter)?;
491                } else {
492                    std::env::set_current_dir(output_base).map_err(|e| {
493                        crate::error::FileSystemError::ReadFileFailed {
494                            path: output_base.display().to_string(),
495                            source: e,
496                        }
497                    })?;
498
499                    let relative_files: Vec<PathBuf> = all_generated_files
500                        .iter()
501                        .filter_map(|p| {
502                            p.strip_prefix(output_base)
503                                .ok()
504                                .map(|p| p.to_path_buf())
505                                .filter(|p| !p.as_os_str().is_empty())
506                        })
507                        .collect();
508
509                    if !relative_files.is_empty() {
510                        let result = FormatterManager::format_files(&relative_files, formatter);
511
512                        std::env::set_current_dir(&original_dir).map_err(|e| {
513                            crate::error::FileSystemError::ReadFileFailed {
514                                path: original_dir.display().to_string(),
515                                source: e,
516                            }
517                        })?;
518
519                        result?;
520
521                        // Update metadata for formatted files to reflect formatted content hash (batch update)
522                        use crate::generator::writer::batch_update_file_metadata_from_disk;
523                        if let Err(e) = batch_update_file_metadata_from_disk(&all_generated_files) {
524                            // Log but don't fail - metadata update is best effort
525                            progress.warning(&format!("Failed to update metadata: {}", e));
526                        }
527                    } else {
528                        std::env::set_current_dir(&original_dir).map_err(|e| {
529                            crate::error::FileSystemError::ReadFileFailed {
530                                path: original_dir.display().to_string(),
531                                source: e,
532                            }
533                        })?;
534                    }
535                }
536            } else {
537                FormatterManager::format_files(&all_generated_files, formatter)?;
538
539                // Update metadata for formatted files to reflect formatted content hash (batch update)
540                use crate::generator::writer::batch_update_file_metadata_from_disk;
541                if let Err(e) = batch_update_file_metadata_from_disk(&all_generated_files) {
542                    // Log but don't fail - metadata update is best effort
543                    progress.warning(&format!("Failed to update metadata: {}", e));
544                }
545            }
546            progress.finish_spinner("Files formatted");
547        }
548    }
549
550    Ok(GenerationStats {
551        spec_name: spec.name.clone(),
552        modules_generated: selected_modules.len(),
553        files_generated: total_files,
554        modules: selected_modules,
555    })
556}
557
558/// Generate code for multiple specs sequentially
559pub async fn run_all_specs(
560    specs: &[SpecEntry],
561    config: &Config,
562    options: &GenerateOptions,
563) -> Result<Vec<GenerationStats>> {
564    let mut stats = Vec::new();
565    for spec in specs {
566        let result = run_single_spec(spec, config, options).await?;
567        stats.push(result);
568    }
569    Ok(stats)
570}
571
572fn collect_ts_files(dir: &std::path::Path, files: &mut Vec<PathBuf>) -> Result<()> {
573    if dir.is_dir() {
574        for entry in
575            std::fs::read_dir(dir).map_err(|e| crate::error::FileSystemError::ReadFileFailed {
576                path: dir.display().to_string(),
577                source: e,
578            })?
579        {
580            let entry = entry.map_err(|e| crate::error::FileSystemError::ReadFileFailed {
581                path: dir.display().to_string(),
582                source: e,
583            })?;
584            let path = entry.path();
585            // Skip if path is empty or invalid
586            if path.as_os_str().is_empty() {
587                continue;
588            }
589            if path.is_dir() {
590                collect_ts_files(&path, files)?;
591            } else if path.extension().and_then(|s| s.to_str()) == Some("ts") {
592                files.push(path);
593            }
594        }
595    }
596    Ok(())
597}