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/// Options for generation
23pub struct GenerateOptions {
24    pub use_cache: bool,
25    pub use_backup: bool,
26    pub use_force: bool,
27    pub verbose: bool,
28}
29
30/// Generate code for a single spec
31pub async fn run_single_spec(
32    spec: &SpecEntry,
33    _config: &Config,
34    options: &GenerateOptions,
35) -> Result<GenerationStats> {
36    let mut progress = ProgressReporter::new(options.verbose);
37    // Always use spec name (even for single spec)
38    let spec_name = Some(spec.name.as_str());
39
40    progress.start_spinner(&format!("Fetching spec from: {}", spec.path));
41    let parsed = crate::generator::swagger_parser::fetch_and_parse_spec_with_cache_and_name(
42        &spec.path,
43        options.use_cache,
44        Some(&spec.name),
45    )
46    .await?;
47    progress.finish_spinner(&format!(
48        "Parsed spec with {} modules",
49        parsed.modules.len()
50    ));
51
52    // Use spec-specific configs (required per spec)
53    let schemas_config = &spec.schemas;
54    let apis_config = &spec.apis;
55    let modules_config = &spec.modules;
56
57    // Filter out ignored modules (using spec-specific or global)
58    let available_modules: Vec<String> = parsed
59        .modules
60        .iter()
61        .filter(|m| !modules_config.ignore.contains(m))
62        .cloned()
63        .collect();
64
65    if available_modules.is_empty() {
66        return Err(crate::error::GenerationError::NoModulesAvailable.into());
67    }
68
69    // Select modules interactively (using spec-specific or global ignore list)
70    let selected_modules = select_modules(&available_modules, &modules_config.ignore)?;
71
72    // Filter common schemas based on selected modules only
73    let (filtered_module_schemas, common_schemas) =
74        filter_common_schemas(&parsed.module_schemas, &selected_modules);
75
76    // Generate code for each module (using spec-specific or global output directories)
77    let schemas_dir = PathBuf::from(&schemas_config.output);
78    let apis_dir = PathBuf::from(&apis_config.output);
79
80    // Ensure http.ts file exists (only once per output directory)
81    let http_file = apis_dir.join("http.ts");
82    if !http_file.exists() {
83        use crate::generator::writer::write_http_client_template;
84        write_http_client_template(&http_file)?;
85        if options.verbose {
86            progress.success(&format!("Created {}", http_file.display()));
87        }
88    }
89
90    let mut total_files = 0;
91
92    // Generate common module first if there are shared schemas
93    if !common_schemas.is_empty() {
94        progress.start_spinner("Generating common schemas...");
95
96        // Shared enum registry to ensure consistent naming between TypeScript and Zod
97        let mut shared_enum_registry = std::collections::HashMap::new();
98
99        // Initialize template engine
100        let project_root = std::env::current_dir().ok();
101        let template_engine =
102            crate::templates::engine::TemplateEngine::new(project_root.as_deref())?;
103
104        // Generate TypeScript typings for common schemas
105        // Pass empty common_schemas list so common schemas don't prefix themselves with "Common."
106        let common_types = generate_typings_with_registry_and_engine_and_spec(
107            &parsed.openapi,
108            &parsed.schemas,
109            &common_schemas,
110            &mut shared_enum_registry,
111            &[], // Empty list - common schemas shouldn't prefix themselves
112            Some(&template_engine),
113            spec_name,
114        )?;
115
116        // Generate Zod schemas for common schemas (using same registry)
117        // Pass empty common_schemas list so common schemas don't prefix themselves with "Common."
118        let common_zod_schemas = generate_zod_schemas_with_registry_and_engine_and_spec(
119            &parsed.openapi,
120            &parsed.schemas,
121            &common_schemas,
122            &mut shared_enum_registry,
123            &[], // Empty list - common schemas shouldn't prefix themselves
124            Some(&template_engine),
125            spec_name,
126        )?;
127
128        // Write common schemas
129        use crate::generator::writer::write_schemas_with_module_mapping;
130        let common_files = write_schemas_with_module_mapping(
131            &schemas_dir,
132            "common",
133            &common_types,
134            &common_zod_schemas,
135            spec_name,
136            options.use_backup,
137            options.use_force,
138            Some(&filtered_module_schemas),
139            &common_schemas,
140        )?;
141        total_files += common_files.len();
142        progress.finish_spinner(&format!(
143            "Generated {} common schema files",
144            common_files.len()
145        ));
146    }
147
148    for module in &selected_modules {
149        progress.start_spinner(&format!("Generating code for module: {}", module));
150
151        // Get operations for this module
152        let operations = parsed
153            .operations_by_tag
154            .get(module)
155            .cloned()
156            .unwrap_or_default();
157
158        if operations.is_empty() {
159            progress.warning(&format!("No operations found for module: {}", module));
160            continue;
161        }
162
163        // Get schema names used by this module (from filtered schemas)
164        let module_schema_names = filtered_module_schemas
165            .get(module)
166            .cloned()
167            .unwrap_or_default();
168
169        // Initialize template engine
170        let project_root = std::env::current_dir().ok();
171        let template_engine =
172            crate::templates::engine::TemplateEngine::new(project_root.as_deref())?;
173
174        // Shared enum registry to ensure consistent naming between TypeScript and Zod
175        let mut shared_enum_registry = std::collections::HashMap::new();
176
177        // Generate TypeScript typings
178        let types = if !module_schema_names.is_empty() {
179            generate_typings_with_registry_and_engine_and_spec(
180                &parsed.openapi,
181                &parsed.schemas,
182                &module_schema_names,
183                &mut shared_enum_registry,
184                &common_schemas,
185                Some(&template_engine),
186                spec_name,
187            )?
188        } else {
189            Vec::new()
190        };
191
192        // Generate Zod schemas (using same registry)
193        let zod_schemas = if !module_schema_names.is_empty() {
194            generate_zod_schemas_with_registry_and_engine_and_spec(
195                &parsed.openapi,
196                &parsed.schemas,
197                &module_schema_names,
198                &mut shared_enum_registry,
199                &common_schemas,
200                Some(&template_engine),
201                spec_name,
202            )?
203        } else {
204            Vec::new()
205        };
206
207        // Generate API client (using same enum registry as schemas)
208        let api_result = generate_api_client_with_registry_and_engine_and_spec(
209            &parsed.openapi,
210            &operations,
211            module,
212            &common_schemas,
213            &mut shared_enum_registry,
214            Some(&template_engine),
215            spec_name,
216        )?;
217
218        // Combine response types with schema types
219        let mut all_types = types;
220        all_types.extend(api_result.response_types);
221
222        // Write schemas (with backup and conflict detection)
223        // Pass module_schemas mapping to enable cross-module enum imports
224        use crate::generator::writer::write_schemas_with_module_mapping;
225        let schema_files = write_schemas_with_module_mapping(
226            &schemas_dir,
227            module,
228            &all_types,
229            &zod_schemas,
230            spec_name,
231            options.use_backup,
232            options.use_force,
233            Some(&filtered_module_schemas),
234            &common_schemas,
235        )?;
236        total_files += schema_files.len();
237
238        // Write API client (with backup and conflict detection)
239        let api_files = write_api_client_with_options(
240            &apis_dir,
241            module,
242            &api_result.functions,
243            spec_name,
244            options.use_backup,
245            options.use_force,
246        )?;
247        total_files += api_files.len();
248
249        progress.finish_spinner(&format!(
250            "Generated {} files for module: {}",
251            schema_files.len() + api_files.len(),
252            module
253        ));
254    }
255
256    // Format all generated files with prettier/biome if available
257    let mut all_generated_files = Vec::new();
258
259    // Collect schema files recursively
260    if schemas_dir.exists() {
261        collect_ts_files(&schemas_dir, &mut all_generated_files)?;
262    }
263
264    // Collect API files recursively
265    if apis_dir.exists() {
266        collect_ts_files(&apis_dir, &mut all_generated_files)?;
267    }
268
269    // Format files if formatter is available
270    if !all_generated_files.is_empty() {
271        // Get current directory to resolve relative paths
272        let current_dir =
273            std::env::current_dir().map_err(|e| crate::error::FileSystemError::ReadFileFailed {
274                path: ".".to_string(),
275                source: e,
276            })?;
277
278        // Resolve to absolute paths
279        let schemas_dir_abs = if schemas_dir.is_absolute() {
280            schemas_dir.clone()
281        } else {
282            current_dir.join(&schemas_dir)
283        };
284        let apis_dir_abs = if apis_dir.is_absolute() {
285            apis_dir.clone()
286        } else {
287            current_dir.join(&apis_dir)
288        };
289
290        let output_base = schemas_dir_abs
291            .parent()
292            .and_then(|p| p.parent())
293            .or_else(|| apis_dir_abs.parent().and_then(|p| p.parent()));
294
295        let formatter = if let Some(base_dir) = output_base {
296            FormatterManager::detect_formatter_from_dir(base_dir)
297                .or_else(FormatterManager::detect_formatter)
298        } else {
299            FormatterManager::detect_formatter()
300        };
301
302        if let Some(formatter) = formatter {
303            progress.start_spinner("Formatting generated files...");
304            let original_dir = std::env::current_dir().map_err(|e| {
305                crate::error::FileSystemError::ReadFileFailed {
306                    path: ".".to_string(),
307                    source: e,
308                }
309            })?;
310
311            if let Some(output_base) = output_base {
312                // Ensure output_base is not empty
313                if output_base.as_os_str().is_empty() {
314                    // Fallback: use current directory
315                    FormatterManager::format_files(&all_generated_files, formatter)?;
316                } else {
317                    std::env::set_current_dir(output_base).map_err(|e| {
318                        crate::error::FileSystemError::ReadFileFailed {
319                            path: output_base.display().to_string(),
320                            source: e,
321                        }
322                    })?;
323
324                    let relative_files: Vec<PathBuf> = all_generated_files
325                        .iter()
326                        .filter_map(|p| {
327                            p.strip_prefix(output_base)
328                                .ok()
329                                .map(|p| p.to_path_buf())
330                                .filter(|p| !p.as_os_str().is_empty())
331                        })
332                        .collect();
333
334                    if !relative_files.is_empty() {
335                        let result = FormatterManager::format_files(&relative_files, formatter);
336
337                        std::env::set_current_dir(&original_dir).map_err(|e| {
338                            crate::error::FileSystemError::ReadFileFailed {
339                                path: original_dir.display().to_string(),
340                                source: e,
341                            }
342                        })?;
343
344                        result?;
345
346                        // Update metadata for formatted files to reflect formatted content hash (batch update)
347                        use crate::generator::writer::batch_update_file_metadata_from_disk;
348                        if let Err(e) = batch_update_file_metadata_from_disk(&all_generated_files) {
349                            // Log but don't fail - metadata update is best effort
350                            progress.warning(&format!("Failed to update metadata: {}", e));
351                        }
352                    } else {
353                        std::env::set_current_dir(&original_dir).map_err(|e| {
354                            crate::error::FileSystemError::ReadFileFailed {
355                                path: original_dir.display().to_string(),
356                                source: e,
357                            }
358                        })?;
359                    }
360                }
361            } else {
362                FormatterManager::format_files(&all_generated_files, formatter)?;
363
364                // Update metadata for formatted files to reflect formatted content hash (batch update)
365                use crate::generator::writer::batch_update_file_metadata_from_disk;
366                if let Err(e) = batch_update_file_metadata_from_disk(&all_generated_files) {
367                    // Log but don't fail - metadata update is best effort
368                    progress.warning(&format!("Failed to update metadata: {}", e));
369                }
370            }
371            progress.finish_spinner("Files formatted");
372        }
373    }
374
375    Ok(GenerationStats {
376        spec_name: spec.name.clone(),
377        modules_generated: selected_modules.len(),
378        files_generated: total_files,
379        modules: selected_modules,
380    })
381}
382
383/// Generate code for multiple specs sequentially
384pub async fn run_all_specs(
385    specs: &[SpecEntry],
386    config: &Config,
387    options: &GenerateOptions,
388) -> Result<Vec<GenerationStats>> {
389    let mut stats = Vec::new();
390    for spec in specs {
391        let result = run_single_spec(spec, config, options).await?;
392        stats.push(result);
393    }
394    Ok(stats)
395}
396
397fn collect_ts_files(dir: &std::path::Path, files: &mut Vec<PathBuf>) -> Result<()> {
398    if dir.is_dir() {
399        for entry in
400            std::fs::read_dir(dir).map_err(|e| crate::error::FileSystemError::ReadFileFailed {
401                path: dir.display().to_string(),
402                source: e,
403            })?
404        {
405            let entry = entry.map_err(|e| crate::error::FileSystemError::ReadFileFailed {
406                path: dir.display().to_string(),
407                source: e,
408            })?;
409            let path = entry.path();
410            // Skip if path is empty or invalid
411            if path.as_os_str().is_empty() {
412                continue;
413            }
414            if path.is_dir() {
415                collect_ts_files(&path, files)?;
416            } else if path.extension().and_then(|s| s.to_str()) == Some("ts") {
417                files.push(path);
418            }
419        }
420    }
421    Ok(())
422}