vika_cli/generator/
writer.rs

1use crate::error::{FileSystemError, Result};
2use crate::generator::api_client::ApiFunction;
3use crate::generator::ts_typings::TypeScriptType;
4use crate::generator::utils::sanitize_module_name;
5use crate::generator::zod_schema::ZodSchema;
6use std::collections::hash_map::DefaultHasher;
7use std::hash::{Hash, Hasher};
8use std::path::{Path, PathBuf};
9use std::time::SystemTime;
10
11pub fn ensure_directory(path: &Path) -> Result<()> {
12    if !path.exists() {
13        std::fs::create_dir_all(path).map_err(|e| FileSystemError::CreateDirectoryFailed {
14            path: path.display().to_string(),
15            source: e,
16        })?;
17    }
18    Ok(())
19}
20
21pub fn write_schemas(
22    output_dir: &Path,
23    module_name: &str,
24    types: &[TypeScriptType],
25    zod_schemas: &[ZodSchema],
26) -> Result<Vec<PathBuf>> {
27    write_schemas_with_options(
28        output_dir,
29        module_name,
30        types,
31        zod_schemas,
32        None,
33        false,
34        false,
35    )
36}
37
38pub fn write_schemas_with_options(
39    output_dir: &Path,
40    module_name: &str,
41    types: &[TypeScriptType],
42    zod_schemas: &[ZodSchema],
43    spec_name: Option<&str>,
44    backup: bool,
45    force: bool,
46) -> Result<Vec<PathBuf>> {
47    write_schemas_with_module_mapping(
48        output_dir,
49        module_name,
50        types,
51        zod_schemas,
52        spec_name,
53        backup,
54        force,
55        None, // module_schemas - will be added later if needed
56        &[],  // common_schemas
57    )
58}
59
60#[allow(clippy::too_many_arguments)]
61pub fn write_schemas_with_module_mapping(
62    output_dir: &Path,
63    module_name: &str,
64    types: &[TypeScriptType],
65    zod_schemas: &[ZodSchema],
66    _spec_name: Option<&str>,
67    backup: bool,
68    force: bool,
69    module_schemas: Option<&std::collections::HashMap<String, Vec<String>>>,
70    common_schemas: &[String],
71) -> Result<Vec<PathBuf>> {
72    // Build module directory path: {output_dir}/{module_name}
73    // Note: output_dir already includes spec_name if needed (from config)
74    // spec_name is only used for import path calculations, not directory structure
75    let module_dir = output_dir.join(sanitize_module_name(module_name));
76    ensure_directory(&module_dir)?;
77
78    let mut written_files = Vec::new();
79
80    // Write TypeScript types
81    if !types.is_empty() {
82        // Deduplicate types by name (to avoid duplicate enum/type declarations)
83        // Extract type name from content: "export type XEnum = ..." or "export interface X { ... }"
84        let mut seen_type_names = std::collections::HashSet::new();
85        let mut deduplicated_types = Vec::new();
86        for t in types {
87            // Extract type name from content
88            let type_name = if let Some(start) = t.content.find("export type ") {
89                let after_export_type = &t.content[start + 12..];
90                if let Some(end) = after_export_type.find([' ', '=', '\n']) {
91                    after_export_type[..end].trim().to_string()
92                } else {
93                    after_export_type.trim().to_string()
94                }
95            } else if let Some(start) = t.content.find("export interface ") {
96                let after_export_interface = &t.content[start + 17..];
97                if let Some(end) = after_export_interface.find([' ', '{', '\n']) {
98                    after_export_interface[..end].trim().to_string()
99                } else {
100                    after_export_interface.trim().to_string()
101                }
102            } else {
103                // Fallback: use full content as key
104                t.content.clone()
105            };
106
107            if !seen_type_names.contains(&type_name) {
108                seen_type_names.insert(type_name);
109                deduplicated_types.push(t);
110            }
111        }
112
113        let types_content_raw = deduplicated_types
114            .iter()
115            .map(|t| t.content.clone())
116            .collect::<Vec<_>>()
117            .join("\n\n");
118
119        // Check if we need to import Common types
120        // In single-spec mode: schemas/<module>/types.ts -> ../common
121        // In multi-spec mode: schemas/<spec_name>/<module>/types.ts -> ../common
122        let needs_common_import = types_content_raw.contains("Common.");
123        let common_import = if needs_common_import {
124            // We're at schemas/{spec_name}/{module}/types.ts (multi-spec) or schemas/{module}/types.ts (single-spec)
125            // Common is at schemas/{spec_name}/common (multi-spec) or schemas/common (single-spec)
126            // So we go up 1 level (module -> spec_name or module -> schemas), then down to common
127            let relative_path = "../";
128            format!("import * as Common from \"{}common\";\n\n", relative_path)
129        } else {
130            String::new()
131        };
132
133        let types_content =
134            format_typescript_code(&format!("{}{}", common_import, types_content_raw));
135
136        let types_file = module_dir.join("types.ts");
137        write_file_with_backup(&types_file, &types_content, backup, force)?;
138        written_files.push(types_file);
139    }
140
141    // Write Zod schemas
142    if !zod_schemas.is_empty() {
143        let zod_content_raw = zod_schemas
144            .iter()
145            .map(|z| z.content.clone())
146            .collect::<Vec<_>>()
147            .join("\n\n");
148
149        // Check if we need to import Common schemas
150        // In single-spec mode: schemas/<module>/schemas.ts -> ../common
151        // In multi-spec mode: schemas/<spec_name>/<module>/schemas.ts -> ../common
152        let needs_common_import = zod_content_raw.contains("Common.");
153        let common_import = if needs_common_import {
154            // We're at schemas/{spec_name}/{module}/schemas.ts (multi-spec) or schemas/{module}/schemas.ts (single-spec)
155            // Common is at schemas/{spec_name}/common (multi-spec) or schemas/common (single-spec)
156            // So we go up 1 level (module -> spec_name or module -> schemas), then down to common
157            let relative_path = "../";
158            format!("import * as Common from \"{}common\";\n\n", relative_path)
159        } else {
160            String::new()
161        };
162
163        // Detect cross-module enum schema references and add imports
164        // This handles cases where a module references an enum from another module
165        // (e.g., orders module using CodeEnumSchema from currencies module)
166        let mut cross_module_imports: std::collections::HashMap<
167            String,
168            std::collections::HashSet<String>,
169        > = std::collections::HashMap::new();
170        if let Some(module_schemas_map) = module_schemas {
171            let _current_module_schemas: std::collections::HashSet<String> = module_schemas_map
172                .get(module_name)
173                .cloned()
174                .unwrap_or_default()
175                .into_iter()
176                .collect();
177
178            // Check which enums are defined locally in this module's zod_schemas
179            let locally_defined_enums: std::collections::HashSet<String> = zod_schemas
180                .iter()
181                .filter_map(|z| {
182                    // Extract enum name from "export const XEnumSchema = z.enum([...])"
183                    if let Some(start) = z.content.find("export const ") {
184                        let after_export = &z.content[start + 13..];
185                        if let Some(end) = after_export.find("EnumSchema") {
186                            let enum_name = &after_export[..end + "EnumSchema".len()];
187                            if enum_name.ends_with("EnumSchema") {
188                                return Some(enum_name.to_string());
189                            }
190                        }
191                    }
192                    None
193                })
194                .collect();
195
196            // Find enum schema references in the content (pattern: XEnumSchema where X is not Common)
197            // We'll search for patterns like "CodeEnumSchema", "CountryCodeEnumSchema", etc.
198            let mut pos = 0;
199            while let Some(start) = zod_content_raw[pos..].find("EnumSchema") {
200                let actual_start = pos + start;
201                // Find the start of the enum name (go backwards to find word boundary)
202                let mut name_start = actual_start;
203                while name_start > 0 {
204                    let ch = zod_content_raw.chars().nth(name_start - 1).unwrap_or(' ');
205                    if !ch.is_alphanumeric() && ch != '_' {
206                        break;
207                    }
208                    name_start -= 1;
209                }
210                let enum_name = &zod_content_raw[name_start..actual_start + "EnumSchema".len()];
211
212                // Skip if it's Common.EnumSchema (already imported)
213                if enum_name.starts_with("Common.") {
214                    pos = actual_start + "EnumSchema".len();
215                    continue;
216                }
217
218                // Skip if this enum is defined locally in this module
219                if locally_defined_enums.contains(enum_name) {
220                    pos = actual_start + "EnumSchema".len();
221                    continue;
222                }
223
224                // Extract schema name from enum name (e.g., CodeEnumSchema -> Code)
225                let schema_name = enum_name.replace("EnumSchema", "");
226
227                // Check if this enum is not defined locally AND not in common
228                // If it's not defined locally but IS in common, we should use Common.EnumSchema instead
229                if !locally_defined_enums.contains(enum_name)
230                    && !common_schemas.contains(&schema_name)
231                {
232                    // Find which module defines this schema (and thus exports the enum)
233                    // Try exact match first
234                    let mut found_module: Option<String> = None;
235                    for (other_module, other_schemas) in module_schemas_map {
236                        if other_module != module_name && other_schemas.contains(&schema_name) {
237                            // Found it! But check if it's not in common schemas
238                            // If it's in common, the enum should be imported from common, not this module
239                            if !common_schemas.contains(&schema_name) {
240                                found_module = Some(other_module.clone());
241                                break;
242                            }
243                        }
244                    }
245
246                    // If not found with exact match, try case-insensitive and partial matches
247                    // This handles cases where schema names might have different casing or prefixes
248                    if found_module.is_none() {
249                        let schema_name_lower = schema_name.to_lowercase();
250                        for (other_module, other_schemas) in module_schemas_map {
251                            if other_module != module_name {
252                                // Check if any schema name matches (case-insensitive or contains the enum name)
253                                for other_schema in other_schemas {
254                                    let other_schema_lower = other_schema.to_lowercase();
255                                    // Match if schema name equals enum base name (case-insensitive)
256                                    // or if enum name is contained in schema name
257                                    if (other_schema_lower == schema_name_lower
258                                        || other_schema_lower.contains(&schema_name_lower)
259                                        || schema_name_lower.contains(&other_schema_lower))
260                                        && !common_schemas.contains(other_schema)
261                                    {
262                                        found_module = Some(other_module.clone());
263                                        break;
264                                    }
265                                }
266                                if found_module.is_some() {
267                                    break;
268                                }
269                            }
270                        }
271                    }
272
273                    // If we found a module, add the import
274                    if let Some(module) = found_module {
275                        cross_module_imports
276                            .entry(module)
277                            .or_default()
278                            .insert(enum_name.to_string());
279                    }
280                    // Note: Disabled heuristic matching as it was too aggressive and caused false imports
281                    // If an enum is truly needed from another module, it should be found via exact or fuzzy schema name match
282                }
283
284                pos = actual_start + "EnumSchema".len();
285            }
286        }
287
288        // Build cross-module imports (deduplicated)
289        let mut cross_module_import_lines = String::new();
290        for (other_module, enum_names_set) in &cross_module_imports {
291            let mut enum_names: Vec<String> = enum_names_set.iter().cloned().collect();
292            enum_names.sort(); // Sort for consistent output
293            if !enum_names.is_empty() {
294                let relative_path = "../";
295                let module_import = format!(
296                    "import {{ {} }} from \"{}{}\";\n",
297                    enum_names.join(", "),
298                    relative_path,
299                    sanitize_module_name(other_module)
300                );
301                cross_module_import_lines.push_str(&module_import);
302            }
303        }
304        if !cross_module_import_lines.is_empty() {
305            cross_module_import_lines.push('\n');
306        }
307
308        let zod_content = format_typescript_code(&format!(
309            "import {{ z }} from \"zod\";\n{}{}{}",
310            if !common_import.is_empty() {
311                &common_import
312            } else {
313                ""
314            },
315            cross_module_import_lines,
316            zod_content_raw
317        ));
318
319        let zod_file = module_dir.join("schemas.ts");
320        write_file_with_backup(&zod_file, &zod_content, backup, force)?;
321        written_files.push(zod_file);
322    }
323
324    // Write index file with namespace export for better organization
325    let mut index_exports = Vec::new();
326    if !types.is_empty() {
327        index_exports.push("export * from \"./types\";".to_string());
328    }
329    if !zod_schemas.is_empty() {
330        index_exports.push("export * from \"./schemas\";".to_string());
331    }
332
333    if !index_exports.is_empty() {
334        // Write index file with regular exports
335        // Note: TypeScript namespaces cannot use export *, so we use regular exports
336        // and import as namespace in API clients for better organization
337        let index_content = format_typescript_code(&(index_exports.join("\n") + "\n"));
338        let index_file = module_dir.join("index.ts");
339        write_file_with_backup(&index_file, &index_content, backup, force)?;
340        written_files.push(index_file);
341    }
342
343    Ok(written_files)
344}
345
346pub fn write_api_client(
347    output_dir: &Path,
348    module_name: &str,
349    functions: &[ApiFunction],
350) -> Result<Vec<PathBuf>> {
351    write_api_client_with_options(output_dir, module_name, functions, None, false, false)
352}
353
354pub fn write_api_client_with_options(
355    output_dir: &Path,
356    module_name: &str,
357    functions: &[ApiFunction],
358    _spec_name: Option<&str>,
359    backup: bool,
360    force: bool,
361) -> Result<Vec<PathBuf>> {
362    // Build module directory path: {output_dir}/{module_name}
363    // Note: output_dir already includes spec_name if needed (from config)
364    // spec_name is only used for import path calculations, not directory structure
365    let module_dir = output_dir.join(sanitize_module_name(module_name));
366    ensure_directory(&module_dir)?;
367
368    let mut written_files = Vec::new();
369
370    if !functions.is_empty() {
371        // Consolidate imports: extract all imports and merge by module
372        // Map: module_path -> (type_imports_set, other_imports_set)
373        // We need to separate type imports from other imports to reconstruct them correctly
374        let mut imports_by_module: std::collections::HashMap<
375            String,
376            (std::collections::HashSet<String>, Vec<String>),
377        > = std::collections::HashMap::new();
378        let mut function_bodies = Vec::new();
379        let mut seen_functions: std::collections::HashSet<String> =
380            std::collections::HashSet::new();
381
382        for func in functions {
383            let lines: Vec<&str> = func.content.lines().collect();
384            let mut func_lines = Vec::new();
385            let mut type_lines = Vec::new();
386            let mut in_function = false;
387            let mut in_type = false;
388            let mut type_definition = Vec::new();
389            let mut brace_count = 0;
390            let mut jsdoc_lines = Vec::new();
391            let mut in_jsdoc = false;
392            let mut function_name: Option<String> = None;
393
394            for line in lines {
395                if line.trim().starts_with("import ") {
396                    let import_line = line.trim().trim_end_matches(';').trim();
397                    // Parse import statement: "import type { A, B } from 'path'" or "import * as X from 'path'"
398                    if let Some(from_pos) = import_line.find(" from ") {
399                        let before_from = &import_line[..from_pos];
400                        let after_from = &import_line[from_pos + 6..];
401                        let module_path = after_from.trim_matches('"').trim_matches('\'').trim();
402
403                        // Extract imported items
404                        if before_from.contains("import type {") {
405                            // Type import: "import type { A, B }"
406                            if let Some(start) = before_from.find('{') {
407                                if let Some(end) = before_from.find('}') {
408                                    let items_str = &before_from[start + 1..end];
409                                    let items: Vec<String> = items_str
410                                        .split(',')
411                                        .map(|s| s.trim().to_string())
412                                        .filter(|s| !s.is_empty())
413                                        .collect();
414
415                                    let (type_imports, _) = imports_by_module
416                                        .entry(module_path.to_string())
417                                        .or_insert_with(|| {
418                                            (std::collections::HashSet::new(), Vec::new())
419                                        });
420                                    type_imports.extend(items);
421                                }
422                            }
423                        } else if before_from.contains("import * as ") {
424                            // Namespace import: "import * as X"
425                            // Keep as-is, don't merge
426                            let (_, other_imports) = imports_by_module
427                                .entry(module_path.to_string())
428                                .or_insert_with(|| (std::collections::HashSet::new(), Vec::new()));
429                            other_imports.push(import_line.to_string());
430                        } else {
431                            // Default import or other format (e.g., "import { http }")
432                            // Keep as-is
433                            let (_, other_imports) = imports_by_module
434                                .entry(module_path.to_string())
435                                .or_insert_with(|| (std::collections::HashSet::new(), Vec::new()));
436                            other_imports.push(import_line.to_string());
437                        }
438                    } else {
439                        // Malformed import - keep as-is
440                        let (_, other_imports) = imports_by_module
441                            .entry("".to_string())
442                            .or_insert_with(|| (std::collections::HashSet::new(), Vec::new()));
443                        other_imports.push(import_line.to_string());
444                    }
445                } else if in_type {
446                    // Inside a type definition - include everything (including JSDoc) as part of the type
447                    type_definition.push(line.to_string());
448                    brace_count +=
449                        line.matches('{').count() as i32 - line.matches('}').count() as i32;
450                    if brace_count == 0 && line.trim().ends_with(';') {
451                        // Type definition complete
452                        type_lines.push(type_definition.join("\n"));
453                        type_definition.clear();
454                        in_type = false;
455                    }
456                } else if line.trim().starts_with("/**") {
457                    // Start of JSDoc comment (only collect if not inside a type)
458                    in_jsdoc = true;
459                    jsdoc_lines.push(line);
460                } else if in_jsdoc {
461                    jsdoc_lines.push(line);
462                    if line.trim().ends_with("*/") {
463                        // End of JSDoc comment
464                        in_jsdoc = false;
465                    }
466                } else if line.trim().starts_with("export const ") {
467                    // Extract function name to check for duplicates
468                    // Find the function name after "export const " (13 chars)
469                    let trimmed = line.trim();
470                    if trimmed.len() > 13 {
471                        let after_export_const = &trimmed[13..];
472                        // Find the first space or opening parenthesis after function name
473                        let name_end = after_export_const
474                            .find(' ')
475                            .or_else(|| after_export_const.find('('))
476                            .unwrap_or(after_export_const.len());
477                        let name = after_export_const[..name_end].trim().to_string();
478                        if !name.is_empty() {
479                            function_name = Some(name.clone());
480                            if seen_functions.contains(&name) {
481                                // Skip duplicate function
482                                jsdoc_lines.clear();
483                                break;
484                            }
485                            seen_functions.insert(name);
486                        }
487                    }
488                    in_function = true;
489                    // Add JSDoc comments before the function
490                    func_lines.append(&mut jsdoc_lines);
491                    func_lines.push(line);
492                } else if in_function {
493                    func_lines.push(line);
494                    // Check if function ends
495                    if line.trim() == "};" {
496                        break;
497                    }
498                } else if line.trim().starts_with("export type ")
499                    || line.trim().starts_with("export interface ")
500                {
501                    // Start of a type/interface definition - capture the complete definition
502                    in_type = true;
503                    type_definition.clear();
504                    type_definition.push(line.to_string());
505                    // Count braces to know when type definition ends
506                    brace_count =
507                        line.matches('{').count() as i32 - line.matches('}').count() as i32;
508                    if brace_count == 0 && line.trim().ends_with(';') {
509                        // Single-line type definition
510                        type_lines.push(type_definition.join("\n"));
511                        type_definition.clear();
512                        in_type = false;
513                    }
514                }
515                // Skip non-exported type definitions - they're in types.ts now
516            }
517
518            // If we're still in a type definition at the end, include it anyway
519            if in_type && !type_definition.is_empty() {
520                type_lines.push(type_definition.join("\n"));
521            }
522
523            // Combine types and function, with types first
524            if !func_lines.is_empty() && function_name.is_some() {
525                let mut combined_content = Vec::new();
526                if !type_lines.is_empty() {
527                    combined_content.extend(type_lines.iter().map(|s| s.to_string()));
528                    combined_content.push(String::new()); // Add blank line between types and function
529                }
530                combined_content.extend(func_lines.iter().map(|s| s.to_string()));
531                function_bodies.push(combined_content.join("\n"));
532            } else if !type_lines.is_empty() {
533                // If we have types but no function (shouldn't happen, but handle it)
534                function_bodies.push(
535                    type_lines
536                        .iter()
537                        .map(|s| s.to_string())
538                        .collect::<Vec<_>>()
539                        .join("\n"),
540                );
541            }
542        }
543
544        // Combine imports and function bodies (no type definitions)
545        // Merge imports by module path
546        // Sort module paths for deterministic import order
547        let mut sorted_module_paths: Vec<String> = imports_by_module.keys().cloned().collect();
548        sorted_module_paths.sort();
549
550        let mut imports_vec = Vec::new();
551        for module_path in sorted_module_paths {
552            let (type_import_items, other_imports) = imports_by_module.get(&module_path).unwrap();
553            if module_path.is_empty() {
554                // Malformed imports - add as-is (deduplicate)
555                let deduped: std::collections::HashSet<String> =
556                    other_imports.iter().cloned().collect();
557                imports_vec.extend(deduped.into_iter());
558            } else {
559                // Deduplicate and separate other imports by type
560                let deduped_imports: std::collections::HashSet<String> =
561                    other_imports.iter().cloned().collect();
562                let mut namespace_imports = Vec::new();
563                let mut default_imports = Vec::new();
564
565                for item in deduped_imports.iter() {
566                    if item.contains("import * as") {
567                        // Namespace import - keep as-is
568                        namespace_imports.push(item.clone());
569                    } else {
570                        // Default import (e.g., "import { http }")
571                        default_imports.push(item.clone());
572                    }
573                }
574
575                // Add namespace imports (sorted for consistency)
576                namespace_imports.sort();
577                for ns_import in namespace_imports {
578                    imports_vec.push(format!("{};", ns_import));
579                }
580
581                // Add default imports (sorted for consistency)
582                default_imports.sort();
583                for default_import in default_imports {
584                    imports_vec.push(format!("{};", default_import));
585                }
586
587                // Merge and add type imports
588                if !type_import_items.is_empty() {
589                    let mut sorted_types: Vec<String> = type_import_items.iter().cloned().collect();
590                    sorted_types.sort();
591                    imports_vec.push(format!(
592                        "import type {{ {} }} from \"{}\";",
593                        sorted_types.join(", "),
594                        module_path
595                    ));
596                }
597            }
598        }
599        let imports_str = imports_vec.join("\n");
600        let functions_str = function_bodies.join("\n\n");
601        let combined_content = if !imports_str.is_empty() {
602            format!("{}\n\n{}", imports_str, functions_str)
603        } else {
604            functions_str
605        };
606
607        let functions_content = format_typescript_code(&combined_content);
608
609        let api_file = module_dir.join("index.ts");
610        write_file_with_backup(&api_file, &functions_content, backup, force)?;
611        written_files.push(api_file);
612    }
613
614    Ok(written_files)
615}
616
617/// Write runtime client files (types, http-client, index) to the runtime directory at root_dir.
618pub fn write_runtime_client(
619    root_dir: &Path,
620    _spec_name: Option<&str>,
621    apis_config: Option<&crate::config::model::ApisConfig>,
622) -> Result<Vec<PathBuf>> {
623    let runtime_dir = root_dir.join("runtime");
624    ensure_directory(&runtime_dir)?;
625
626    let project_root = std::env::current_dir().ok();
627    let template_engine = crate::templates::engine::TemplateEngine::new(project_root.as_deref())?;
628
629    let mut written_files = Vec::new();
630
631    // Generate types.ts
632    let types_content = template_engine.render(
633        crate::templates::registry::TemplateId::RuntimeTypes,
634        &serde_json::json!({}),
635    )?;
636    let types_file = runtime_dir.join("types.ts");
637    write_file_safe(&types_file, &types_content)?;
638    written_files.push(types_file);
639
640    // Prepare runtime client options from config
641    let mut client_options = serde_json::Map::new();
642    if let Some(config) = apis_config {
643        if let Some(ref base_url) = config.base_url {
644            client_options.insert(
645                "baseUrl".to_string(),
646                serde_json::Value::String(base_url.clone()),
647            );
648        }
649        if let Some(timeout) = config.timeout {
650            client_options.insert(
651                "timeout".to_string(),
652                serde_json::Value::Number(timeout.into()),
653            );
654        }
655        if let Some(retries) = config.retries {
656            client_options.insert(
657                "retries".to_string(),
658                serde_json::Value::Number(retries.into()),
659            );
660        }
661        if let Some(retry_delay) = config.retry_delay {
662            client_options.insert(
663                "retryDelay".to_string(),
664                serde_json::Value::Number(retry_delay.into()),
665            );
666        }
667        if let Some(ref headers) = config.headers {
668            // Convert HashMap to JSON object for template
669            let headers_json: serde_json::Value =
670                serde_json::to_value(headers).unwrap_or(serde_json::json!({}));
671            client_options.insert("headers".to_string(), headers_json);
672        }
673        // Map header_strategy to auth
674        let auth_strategy = match config.header_strategy.as_str() {
675            "bearerToken" => Some("bearerToken"),
676            "fixed" => Some("fixed"),
677            "consumerInjected" => Some("consumerInjected"),
678            _ => None,
679        };
680        if let Some(auth) = auth_strategy {
681            client_options.insert(
682                "auth".to_string(),
683                serde_json::Value::String(auth.to_string()),
684            );
685        }
686    }
687    let client_options_value = serde_json::Value::Object(client_options);
688
689    // Generate http-client.ts with config options
690    let http_client_content = template_engine.render(
691        crate::templates::registry::TemplateId::RuntimeHttpClient,
692        &client_options_value,
693    )?;
694    let http_client_file = runtime_dir.join("http-client.ts");
695    write_file_safe(&http_client_file, &http_client_content)?;
696    written_files.push(http_client_file);
697
698    // Generate index.ts
699    let index_content = template_engine.render(
700        crate::templates::registry::TemplateId::RuntimeIndex,
701        &serde_json::json!({}),
702    )?;
703    let index_file = runtime_dir.join("index.ts");
704    write_file_safe(&index_file, &index_content)?;
705    written_files.push(index_file);
706
707    Ok(written_files)
708}
709
710fn format_typescript_code(code: &str) -> String {
711    // Basic formatting: remove extra blank lines while preserving indentation
712    let lines: Vec<&str> = code.lines().collect();
713    let mut formatted = Vec::new();
714    let mut last_was_empty = false;
715
716    for line in lines {
717        if line.trim().is_empty() {
718            if !last_was_empty && !formatted.is_empty() {
719                formatted.push(String::new());
720                last_was_empty = true;
721            }
722            continue;
723        }
724        last_was_empty = false;
725        formatted.push(line.to_string());
726    }
727
728    // Remove trailing empty lines
729    while formatted.last().map(|s| s.is_empty()).unwrap_or(false) {
730        formatted.pop();
731    }
732
733    formatted.join("\n")
734}
735
736pub fn write_file_safe(path: &Path, content: &str) -> Result<()> {
737    write_file_with_backup(path, content, false, false)
738}
739
740pub fn write_file_with_backup(path: &Path, content: &str, backup: bool, force: bool) -> Result<()> {
741    // Check if file exists and content is different
742    let file_exists = path.exists();
743    let should_write = if file_exists {
744        if let Ok(existing_content) = std::fs::read_to_string(path) {
745            existing_content != content
746        } else {
747            true
748        }
749    } else {
750        true
751    };
752
753    if !should_write {
754        // Content is the same, skip writing
755        return Ok(());
756    }
757
758    // Create backup if requested and file exists
759    if backup && file_exists {
760        create_backup(path)?;
761    }
762
763    // Check for conflicts (user modifications) if not forcing
764    if !force && file_exists {
765        if let Ok(metadata) = load_file_metadata(path) {
766            let current_hash = compute_content_hash(content);
767            let file_hash = compute_file_hash(path)?;
768
769            // If metadata hash doesn't match current or file hash, check if it's just formatting
770            if metadata.hash != current_hash && metadata.hash != file_hash {
771                // Try to detect formatter by walking up the directory tree
772                // This handles the case where file was formatted but spec didn't change
773                use crate::formatter::FormatterManager;
774
775                // Find formatter by checking parent directories (where config files are likely located)
776                let mut search_dir = path.parent().unwrap_or_else(|| Path::new("."));
777                let mut formatter = None;
778
779                // Walk up the directory tree to find formatter config
780                while search_dir != Path::new("/") && search_dir != Path::new("") {
781                    if let Some(fmt) = FormatterManager::detect_formatter_from_dir(search_dir) {
782                        formatter = Some(fmt);
783                        break;
784                    }
785                    if let Some(parent) = search_dir.parent() {
786                        search_dir = parent;
787                    } else {
788                        break;
789                    }
790                }
791
792                // Also try current directory as fallback
793                if formatter.is_none() {
794                    formatter = FormatterManager::detect_formatter();
795                }
796
797                if let Some(fmt) = formatter {
798                    // Format the new content and compare with file
799                    match FormatterManager::format_content(content, fmt, path) {
800                        Ok(formatted_content) => {
801                            let formatted_hash = compute_content_hash(&formatted_content);
802                            if formatted_hash == file_hash {
803                                // File matches formatted version of new content - it's just formatting, allow overwrite
804                                // Continue to write the file
805                            } else {
806                                // File doesn't match formatted new content
807                                // Check if spec changed - if so, differences are expected
808                                if current_hash == metadata.hash {
809                                    // Spec didn't change, so file should match formatted version if it's just formatting
810                                    // Since it doesn't match, it's likely a user modification
811                                    return Err(FileSystemError::FileModifiedByUser {
812                                        path: path.display().to_string(),
813                                    }
814                                    .into());
815                                }
816                                // Spec changed - file differences are expected, allow overwrite
817                                // (formatted new content won't match formatted old content when spec changes)
818                            }
819                        }
820                        Err(_) => {
821                            // Formatting failed - check if spec changed
822                            if current_hash == metadata.hash {
823                                // Spec didn't change but formatting failed - can't verify
824                                // Since metadata update after formatting should handle this, allow overwrite
825                            }
826                            // If spec changed, allow overwrite (differences are expected)
827                        }
828                    }
829                } else {
830                    // No formatter detected - check if spec changed
831                    if current_hash == metadata.hash {
832                        // Spec didn't change, but file_hash != metadata.hash
833                        // This likely means file was formatted, but we can't verify without formatter
834                        // Since metadata update after formatting should handle this, allow overwrite
835                    }
836                    // If spec changed, allow overwrite (differences are expected)
837                }
838            }
839        }
840    }
841
842    // Write the file
843    std::fs::write(path, content).map_err(|e| FileSystemError::WriteFileFailed {
844        path: path.display().to_string(),
845        source: e,
846    })?;
847
848    // Save metadata
849    save_file_metadata(path, content)?;
850
851    Ok(())
852}
853
854fn create_backup(path: &Path) -> Result<()> {
855    use std::collections::hash_map::DefaultHasher;
856    use std::hash::{Hash, Hasher};
857    use std::time::{SystemTime, UNIX_EPOCH};
858
859    let timestamp = SystemTime::now()
860        .duration_since(UNIX_EPOCH)
861        .unwrap()
862        .as_secs();
863
864    let backup_dir = PathBuf::from(format!(".vika-backup/{}", timestamp));
865    std::fs::create_dir_all(&backup_dir).map_err(|e| FileSystemError::CreateDirectoryFailed {
866        path: backup_dir.display().to_string(),
867        source: e,
868    })?;
869
870    // Determine backup path
871    let backup_path = if path.is_absolute() {
872        // For absolute paths (e.g., from temp directories in tests),
873        // use a hash-based filename to avoid very long paths
874        let path_str = path.display().to_string();
875        let mut hasher = DefaultHasher::new();
876        path_str.hash(&mut hasher);
877        let hash = format!("{:x}", hasher.finish());
878        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
879        backup_dir.join(format!("{}_{}", hash, filename))
880    } else {
881        // For relative paths, preserve directory structure
882        let relative_path = path.strip_prefix(".").unwrap_or(path);
883        backup_dir.join(relative_path)
884    };
885
886    if let Some(parent) = backup_path.parent() {
887        std::fs::create_dir_all(parent).map_err(|e| FileSystemError::CreateDirectoryFailed {
888            path: parent.display().to_string(),
889            source: e,
890        })?;
891    }
892
893    std::fs::copy(path, &backup_path).map_err(|e| FileSystemError::WriteFileFailed {
894        path: backup_path.display().to_string(),
895        source: e,
896    })?;
897
898    Ok(())
899}
900
901#[derive(Clone, serde::Serialize, serde::Deserialize)]
902struct FileMetadata {
903    hash: String,
904    generated_at: u64,
905    generated_by: String,
906}
907
908fn compute_content_hash(content: &str) -> String {
909    let mut hasher = DefaultHasher::new();
910    content.hash(&mut hasher);
911    format!("{:x}", hasher.finish())
912}
913
914fn compute_file_hash(path: &Path) -> Result<String> {
915    let content = std::fs::read_to_string(path).map_err(|e| FileSystemError::ReadFileFailed {
916        path: path.display().to_string(),
917        source: e,
918    })?;
919    Ok(compute_content_hash(&content))
920}
921
922/// Update metadata for a file from its current content on disk
923/// Useful after formatting files to update metadata hash
924pub fn update_file_metadata_from_disk(path: &Path) -> Result<()> {
925    let content = std::fs::read_to_string(path).map_err(|e| FileSystemError::ReadFileFailed {
926        path: path.display().to_string(),
927        source: e,
928    })?;
929    save_file_metadata(path, &content)
930}
931
932/// Batch update metadata for multiple files from disk
933/// Much more efficient than calling update_file_metadata_from_disk for each file
934/// Reads metadata JSON once, updates all files, writes once
935pub fn batch_update_file_metadata_from_disk(paths: &[PathBuf]) -> Result<()> {
936    if paths.is_empty() {
937        return Ok(());
938    }
939
940    let metadata_dir = PathBuf::from(".vika-cache");
941    std::fs::create_dir_all(&metadata_dir).map_err(|e| FileSystemError::CreateDirectoryFailed {
942        path: metadata_dir.display().to_string(),
943        source: e,
944    })?;
945
946    let metadata_file = metadata_dir.join("file-metadata.json");
947    let mut metadata_map: std::collections::HashMap<String, FileMetadata> =
948        if metadata_file.exists() {
949            let content = std::fs::read_to_string(&metadata_file).map_err(|e| {
950                FileSystemError::ReadFileFailed {
951                    path: metadata_file.display().to_string(),
952                    source: e,
953                }
954            })?;
955            serde_json::from_str(&content).unwrap_or_default()
956        } else {
957            std::collections::HashMap::new()
958        };
959
960    let generated_at = SystemTime::now()
961        .duration_since(std::time::UNIX_EPOCH)
962        .unwrap()
963        .as_secs();
964
965    // Update metadata for all files in batch
966    for path in paths {
967        match std::fs::read_to_string(path) {
968            Ok(content) => {
969                let hash = compute_content_hash(&content);
970                metadata_map.insert(
971                    path.display().to_string(),
972                    FileMetadata {
973                        hash,
974                        generated_at,
975                        generated_by: "vika-cli".to_string(),
976                    },
977                );
978            }
979            Err(e) => {
980                // Log but continue with other files
981                eprintln!("Warning: Failed to read {}: {}", path.display(), e);
982            }
983        }
984    }
985
986    // Write updated metadata once
987    let json = serde_json::to_string_pretty(&metadata_map).map_err(|e| {
988        FileSystemError::WriteFileFailed {
989            path: metadata_file.display().to_string(),
990            source: std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)),
991        }
992    })?;
993
994    std::fs::write(&metadata_file, json).map_err(|e| FileSystemError::WriteFileFailed {
995        path: metadata_file.display().to_string(),
996        source: e,
997    })?;
998
999    Ok(())
1000}
1001
1002pub fn save_file_metadata(path: &Path, content: &str) -> Result<()> {
1003    let metadata_dir = PathBuf::from(".vika-cache");
1004    std::fs::create_dir_all(&metadata_dir).map_err(|e| FileSystemError::CreateDirectoryFailed {
1005        path: metadata_dir.display().to_string(),
1006        source: e,
1007    })?;
1008
1009    let metadata_file = metadata_dir.join("file-metadata.json");
1010    let mut metadata_map: std::collections::HashMap<String, FileMetadata> =
1011        if metadata_file.exists() {
1012            let content = std::fs::read_to_string(&metadata_file).map_err(|e| {
1013                FileSystemError::ReadFileFailed {
1014                    path: metadata_file.display().to_string(),
1015                    source: e,
1016                }
1017            })?;
1018            serde_json::from_str(&content).unwrap_or_default()
1019        } else {
1020            std::collections::HashMap::new()
1021        };
1022
1023    let hash = compute_content_hash(content);
1024    let generated_at = SystemTime::now()
1025        .duration_since(std::time::UNIX_EPOCH)
1026        .unwrap()
1027        .as_secs();
1028
1029    metadata_map.insert(
1030        path.display().to_string(),
1031        FileMetadata {
1032            hash,
1033            generated_at,
1034            generated_by: "vika-cli".to_string(),
1035        },
1036    );
1037
1038    let json = serde_json::to_string_pretty(&metadata_map).map_err(|e| {
1039        FileSystemError::WriteFileFailed {
1040            path: metadata_file.display().to_string(),
1041            source: std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)),
1042        }
1043    })?;
1044
1045    std::fs::write(&metadata_file, json).map_err(|e| FileSystemError::WriteFileFailed {
1046        path: metadata_file.display().to_string(),
1047        source: e,
1048    })?;
1049
1050    Ok(())
1051}
1052
1053fn load_file_metadata(path: &Path) -> Result<FileMetadata> {
1054    let metadata_file = PathBuf::from(".vika-cache/file-metadata.json");
1055    if !metadata_file.exists() {
1056        return Err(FileSystemError::FileNotFound {
1057            path: metadata_file.display().to_string(),
1058        }
1059        .into());
1060    }
1061
1062    let content =
1063        std::fs::read_to_string(&metadata_file).map_err(|e| FileSystemError::ReadFileFailed {
1064            path: metadata_file.display().to_string(),
1065            source: e,
1066        })?;
1067
1068    let metadata_map: std::collections::HashMap<String, FileMetadata> =
1069        serde_json::from_str(&content).map_err(|e| FileSystemError::ReadFileFailed {
1070            path: metadata_file.display().to_string(),
1071            source: std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)),
1072        })?;
1073
1074    metadata_map
1075        .get(&path.display().to_string())
1076        .cloned()
1077        .ok_or_else(|| {
1078            FileSystemError::FileNotFound {
1079                path: path.display().to_string(),
1080            }
1081            .into()
1082        })
1083}
1084
1085/// Write hook files to the output directory.
1086pub fn write_hooks_with_options(
1087    output_dir: &Path,
1088    module_name: &str,
1089    hooks: &[crate::generator::hooks::HookFile],
1090    _spec_name: Option<&str>,
1091    backup: bool,
1092    force: bool,
1093) -> Result<Vec<PathBuf>> {
1094    // Build module directory path: {output_dir}/{module_name}
1095    // Note: output_dir already includes spec_name if needed (from config or caller)
1096    let module_dir = output_dir.join(sanitize_module_name(module_name));
1097    ensure_directory(&module_dir)?;
1098
1099    let mut written_files = Vec::new();
1100
1101    for hook in hooks {
1102        let hook_file = module_dir.join(&hook.filename);
1103        write_file_with_backup(&hook_file, &hook.content, backup, force)?;
1104        written_files.push(hook_file);
1105    }
1106
1107    Ok(written_files)
1108}
1109
1110/// Write query keys file to the output directory.
1111pub fn write_query_keys_with_options(
1112    output_dir: &Path,
1113    module_name: &str,
1114    query_keys_content: &str,
1115    _spec_name: Option<&str>,
1116    backup: bool,
1117    force: bool,
1118) -> Result<PathBuf> {
1119    // Build query keys directory path: {output_dir}/
1120    // Note: output_dir already includes spec_name if needed (from config or caller)
1121    ensure_directory(output_dir)?;
1122
1123    // Generate filename: {module_name}.ts
1124    let filename = format!("{}.ts", sanitize_module_name(module_name));
1125    let query_keys_file = output_dir.join(&filename);
1126
1127    write_file_with_backup(&query_keys_file, query_keys_content, backup, force)?;
1128
1129    Ok(query_keys_file)
1130}