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(output_dir, module_name, types, zod_schemas, false, false)
28}
29
30pub fn write_schemas_with_options(
31    output_dir: &Path,
32    module_name: &str,
33    types: &[TypeScriptType],
34    zod_schemas: &[ZodSchema],
35    backup: bool,
36    force: bool,
37) -> Result<Vec<PathBuf>> {
38    let module_dir = output_dir.join(sanitize_module_name(module_name));
39    ensure_directory(&module_dir)?;
40
41    let mut written_files = Vec::new();
42
43    // Write TypeScript types
44    if !types.is_empty() {
45        // Deduplicate types by name (to avoid duplicate enum/type declarations)
46        // Extract type name from content: "export type XEnum = ..." or "export interface X { ... }"
47        let mut seen_type_names = std::collections::HashSet::new();
48        let mut deduplicated_types = Vec::new();
49        for t in types {
50            // Extract type name from content
51            let type_name = if let Some(start) = t.content.find("export type ") {
52                let after_export_type = &t.content[start + 12..];
53                if let Some(end) = after_export_type.find([' ', '=', '\n']) {
54                    after_export_type[..end].trim().to_string()
55                } else {
56                    after_export_type.trim().to_string()
57                }
58            } else if let Some(start) = t.content.find("export interface ") {
59                let after_export_interface = &t.content[start + 17..];
60                if let Some(end) = after_export_interface.find([' ', '{', '\n']) {
61                    after_export_interface[..end].trim().to_string()
62                } else {
63                    after_export_interface.trim().to_string()
64                }
65            } else {
66                // Fallback: use full content as key
67                t.content.clone()
68            };
69
70            if !seen_type_names.contains(&type_name) {
71                seen_type_names.insert(type_name);
72                deduplicated_types.push(t);
73            }
74        }
75
76        let types_content_raw = deduplicated_types
77            .iter()
78            .map(|t| t.content.clone())
79            .collect::<Vec<_>>()
80            .join("\n\n");
81
82        // Check if we need to import Common types
83        let needs_common_import = types_content_raw.contains("Common.");
84        let common_import = if needs_common_import {
85            // Calculate relative path based on module depth
86            let depth = module_name.matches('/').count() + 1;
87            let relative_path = "../".repeat(depth);
88            format!("import * as Common from \"{}common\";\n\n", relative_path)
89        } else {
90            String::new()
91        };
92
93        let types_content =
94            format_typescript_code(&format!("{}{}", common_import, types_content_raw));
95
96        let types_file = module_dir.join("types.ts");
97        write_file_with_backup(&types_file, &types_content, backup, force)?;
98        written_files.push(types_file);
99    }
100
101    // Write Zod schemas
102    if !zod_schemas.is_empty() {
103        let zod_content_raw = zod_schemas
104            .iter()
105            .map(|z| z.content.clone())
106            .collect::<Vec<_>>()
107            .join("\n\n");
108
109        // Check if we need to import Common schemas
110        let needs_common_import = zod_content_raw.contains("Common.");
111        let common_import = if needs_common_import {
112            // Calculate relative path based on module depth
113            let depth = module_name.matches('/').count() + 1;
114            let relative_path = "../".repeat(depth);
115            format!("import * as Common from \"{}common\";\n\n", relative_path)
116        } else {
117            String::new()
118        };
119
120        let zod_content = format_typescript_code(&format!(
121            "import {{ z }} from \"zod\";\n{}{}",
122            if !common_import.is_empty() {
123                &common_import
124            } else {
125                ""
126            },
127            zod_content_raw
128        ));
129
130        let zod_file = module_dir.join("schemas.ts");
131        write_file_with_backup(&zod_file, &zod_content, backup, force)?;
132        written_files.push(zod_file);
133    }
134
135    // Write index file with namespace export for better organization
136    let mut index_exports = Vec::new();
137    if !types.is_empty() {
138        index_exports.push("export * from \"./types\";".to_string());
139    }
140    if !zod_schemas.is_empty() {
141        index_exports.push("export * from \"./schemas\";".to_string());
142    }
143
144    if !index_exports.is_empty() {
145        // Write index file with regular exports
146        // Note: TypeScript namespaces cannot use export *, so we use regular exports
147        // and import as namespace in API clients for better organization
148        let index_content = format_typescript_code(&(index_exports.join("\n") + "\n"));
149        let index_file = module_dir.join("index.ts");
150        write_file_with_backup(&index_file, &index_content, backup, force)?;
151        written_files.push(index_file);
152    }
153
154    Ok(written_files)
155}
156
157pub fn write_api_client(
158    output_dir: &Path,
159    module_name: &str,
160    functions: &[ApiFunction],
161) -> Result<Vec<PathBuf>> {
162    write_api_client_with_options(output_dir, module_name, functions, false, false)
163}
164
165pub fn write_api_client_with_options(
166    output_dir: &Path,
167    module_name: &str,
168    functions: &[ApiFunction],
169    backup: bool,
170    force: bool,
171) -> Result<Vec<PathBuf>> {
172    let module_dir = output_dir.join(sanitize_module_name(module_name));
173    ensure_directory(&module_dir)?;
174
175    let mut written_files = Vec::new();
176
177    if !functions.is_empty() {
178        // Consolidate imports: extract all imports and merge by module
179        // Map: module_path -> (type_imports_set, other_imports_set)
180        // We need to separate type imports from other imports to reconstruct them correctly
181        let mut imports_by_module: std::collections::HashMap<
182            String,
183            (std::collections::HashSet<String>, Vec<String>),
184        > = std::collections::HashMap::new();
185        let mut function_bodies = Vec::new();
186        let mut seen_functions: std::collections::HashSet<String> =
187            std::collections::HashSet::new();
188
189        for func in functions {
190            let lines: Vec<&str> = func.content.lines().collect();
191            let mut func_lines = Vec::new();
192            let mut in_function = false;
193            let mut jsdoc_lines = Vec::new();
194            let mut in_jsdoc = false;
195            let mut function_name: Option<String> = None;
196
197            for line in lines {
198                if line.trim().starts_with("import ") {
199                    let import_line = line.trim().trim_end_matches(';').trim();
200                    // Parse import statement: "import type { A, B } from 'path'" or "import * as X from 'path'"
201                    if let Some(from_pos) = import_line.find(" from ") {
202                        let before_from = &import_line[..from_pos];
203                        let after_from = &import_line[from_pos + 6..];
204                        let module_path = after_from.trim_matches('"').trim_matches('\'').trim();
205
206                        // Extract imported items
207                        if before_from.contains("import type {") {
208                            // Type import: "import type { A, B }"
209                            if let Some(start) = before_from.find('{') {
210                                if let Some(end) = before_from.find('}') {
211                                    let items_str = &before_from[start + 1..end];
212                                    let items: Vec<String> = items_str
213                                        .split(',')
214                                        .map(|s| s.trim().to_string())
215                                        .filter(|s| !s.is_empty())
216                                        .collect();
217
218                                    let (type_imports, _) = imports_by_module
219                                        .entry(module_path.to_string())
220                                        .or_insert_with(|| {
221                                            (std::collections::HashSet::new(), Vec::new())
222                                        });
223                                    type_imports.extend(items);
224                                }
225                            }
226                        } else if before_from.contains("import * as ") {
227                            // Namespace import: "import * as X"
228                            // Keep as-is, don't merge
229                            let (_, other_imports) = imports_by_module
230                                .entry(module_path.to_string())
231                                .or_insert_with(|| (std::collections::HashSet::new(), Vec::new()));
232                            other_imports.push(import_line.to_string());
233                        } else {
234                            // Default import or other format (e.g., "import { http }")
235                            // Keep as-is
236                            let (_, other_imports) = imports_by_module
237                                .entry(module_path.to_string())
238                                .or_insert_with(|| (std::collections::HashSet::new(), Vec::new()));
239                            other_imports.push(import_line.to_string());
240                        }
241                    } else {
242                        // Malformed import - keep as-is
243                        let (_, other_imports) = imports_by_module
244                            .entry("".to_string())
245                            .or_insert_with(|| (std::collections::HashSet::new(), Vec::new()));
246                        other_imports.push(import_line.to_string());
247                    }
248                } else if line.trim().starts_with("/**") {
249                    // Start of JSDoc comment
250                    in_jsdoc = true;
251                    jsdoc_lines.push(line);
252                } else if in_jsdoc {
253                    jsdoc_lines.push(line);
254                    if line.trim().ends_with("*/") {
255                        // End of JSDoc comment
256                        in_jsdoc = false;
257                    }
258                } else if line.trim().starts_with("export const ") {
259                    // Extract function name to check for duplicates
260                    // Find the function name after "export const " (13 chars)
261                    let trimmed = line.trim();
262                    if trimmed.len() > 13 {
263                        let after_export_const = &trimmed[13..];
264                        // Find the first space or opening parenthesis after function name
265                        let name_end = after_export_const
266                            .find(' ')
267                            .or_else(|| after_export_const.find('('))
268                            .unwrap_or(after_export_const.len());
269                        let name = after_export_const[..name_end].trim().to_string();
270                        if !name.is_empty() {
271                            function_name = Some(name.clone());
272                            if seen_functions.contains(&name) {
273                                // Skip duplicate function
274                                jsdoc_lines.clear();
275                                break;
276                            }
277                            seen_functions.insert(name);
278                        }
279                    }
280                    in_function = true;
281                    // Add JSDoc comments before the function
282                    func_lines.extend(jsdoc_lines.drain(..));
283                    func_lines.push(line);
284                } else if in_function {
285                    func_lines.push(line);
286                    // Check if function ends
287                    if line.trim() == "};" {
288                        break;
289                    }
290                }
291                // Skip type definitions - they're in types.ts now
292            }
293
294            if !func_lines.is_empty() && function_name.is_some() {
295                function_bodies.push(func_lines.join("\n"));
296            }
297        }
298
299        // Combine imports and function bodies (no type definitions)
300        // Merge imports by module path
301        let mut imports_vec = Vec::new();
302        for (module_path, (type_import_items, other_imports)) in imports_by_module.iter() {
303            if module_path.is_empty() {
304                // Malformed imports - add as-is (deduplicate)
305                let deduped: std::collections::HashSet<String> =
306                    other_imports.iter().cloned().collect();
307                imports_vec.extend(deduped.into_iter());
308            } else {
309                // Deduplicate and separate other imports by type
310                let deduped_imports: std::collections::HashSet<String> =
311                    other_imports.iter().cloned().collect();
312                let mut namespace_imports = Vec::new();
313                let mut default_imports = Vec::new();
314
315                for item in deduped_imports.iter() {
316                    if item.contains("import * as") {
317                        // Namespace import - keep as-is
318                        namespace_imports.push(item.clone());
319                    } else {
320                        // Default import (e.g., "import { http }")
321                        default_imports.push(item.clone());
322                    }
323                }
324
325                // Add namespace imports (sorted for consistency)
326                namespace_imports.sort();
327                for ns_import in namespace_imports {
328                    imports_vec.push(format!("{};", ns_import));
329                }
330
331                // Add default imports (sorted for consistency)
332                default_imports.sort();
333                for default_import in default_imports {
334                    imports_vec.push(format!("{};", default_import));
335                }
336
337                // Merge and add type imports
338                if !type_import_items.is_empty() {
339                    let mut sorted_types: Vec<String> = type_import_items.iter().cloned().collect();
340                    sorted_types.sort();
341                    imports_vec.push(format!(
342                        "import type {{ {} }} from \"{}\";",
343                        sorted_types.join(", "),
344                        module_path
345                    ));
346                }
347            }
348        }
349        let imports_str = imports_vec.join("\n");
350        let functions_str = function_bodies.join("\n\n");
351        let combined_content = if !imports_str.is_empty() {
352            format!("{}\n\n{}", imports_str, functions_str)
353        } else {
354            functions_str
355        };
356
357        let functions_content = format_typescript_code(&combined_content);
358
359        let api_file = module_dir.join("index.ts");
360        write_file_with_backup(&api_file, &functions_content, backup, force)?;
361        written_files.push(api_file);
362    }
363
364    Ok(written_files)
365}
366
367pub fn write_http_client_template(output_path: &Path) -> Result<()> {
368    ensure_directory(output_path.parent().unwrap_or(Path::new(".")))?;
369
370    let http_client_content = r#"const requestInitIndicators = [
371  "method",
372  "headers",
373  "body",
374  "signal",
375  "credentials",
376  "cache",
377  "redirect",
378  "referrer",
379  "referrerPolicy",
380  "integrity",
381  "keepalive",
382  "mode",
383  "priority",
384  "window",
385];
386
387const isRequestInitLike = (value: unknown): value is RequestInit => {
388  if (!value || typeof value !== "object") {
389    return false;
390  }
391  const candidate = value as Record<string, unknown>;
392  return requestInitIndicators.some((key) => key in candidate);
393};
394
395export const http = {
396  // GET helper. Second argument can be either a RequestInit or a JSON body for uncommon GET-with-body endpoints.
397  async get<T = any>(url: string, optionsOrBody?: RequestInit | unknown): Promise<T> {
398    let init: RequestInit = { method: "GET", body: null };
399
400    if (optionsOrBody !== undefined && optionsOrBody !== null) {
401      if (isRequestInitLike(optionsOrBody)) {
402        const candidate = optionsOrBody as RequestInit;
403        init = {
404          ...candidate,
405          method: "GET",
406          body: candidate.body ?? null,
407        };
408      } else {
409        init = {
410          method: "GET",
411          headers: {
412            "Content-Type": "application/json",
413          },
414          body: JSON.stringify(optionsOrBody),
415        };
416      }
417    }
418
419    const response = await fetch(url, {
420      ...init,
421    });
422    if (!response.ok) {
423      throw new Error(`HTTP error! status: ${response.status}`);
424    }
425    return response.json();
426  },
427
428  async post<T = any>(url: string, body?: any, options: RequestInit = {}): Promise<T> {
429    const response = await fetch(url, {
430      ...options,
431      method: "POST",
432      headers: {
433        "Content-Type": "application/json",
434        ...(options.headers || {}),
435      },
436      body: body !== undefined ? JSON.stringify(body) : (options.body ?? null),
437    });
438    if (!response.ok) {
439      throw new Error(`HTTP error! status: ${response.status}`);
440    }
441    return response.json();
442  },
443
444  async put<T = any>(url: string, body?: any, options: RequestInit = {}): Promise<T> {
445    const response = await fetch(url, {
446      ...options,
447      method: "PUT",
448      headers: {
449        "Content-Type": "application/json",
450        ...(options.headers || {}),
451      },
452      body: body !== undefined ? JSON.stringify(body) : (options.body ?? null),
453    });
454    if (!response.ok) {
455      throw new Error(`HTTP error! status: ${response.status}`);
456    }
457    return response.json();
458  },
459
460  async delete<T = any>(url: string, options: RequestInit = {}): Promise<T> {
461    const response = await fetch(url, {
462      ...options,
463      method: "DELETE",
464      body: options.body ?? null,
465    });
466    if (!response.ok) {
467      throw new Error(`HTTP error! status: ${response.status}`);
468    }
469    return response.json();
470  },
471
472  async patch<T = any>(url: string, body?: any, options: RequestInit = {}): Promise<T> {
473    const response = await fetch(url, {
474      ...options,
475      method: "PATCH",
476      headers: {
477        "Content-Type": "application/json",
478        ...(options.headers || {}),
479      },
480      body: body !== undefined ? JSON.stringify(body) : (options.body ?? null),
481    });
482    if (!response.ok) {
483      throw new Error(`HTTP error! status: ${response.status}`);
484    }
485    return response.json();
486  },
487
488  async head(url: string, options: RequestInit = {}): Promise<Response> {
489    const response = await fetch(url, {
490      ...options,
491      method: "HEAD",
492      body: options.body ?? null,
493    });
494    if (!response.ok) {
495      throw new Error(`HTTP error! status: ${response.status}`);
496    }
497    return response;
498  },
499
500  async options<T = any>(url: string, options: RequestInit = {}): Promise<T> {
501    const response = await fetch(url, {
502      ...options,
503      method: "OPTIONS",
504      body: options.body ?? null,
505    });
506    if (!response.ok) {
507      throw new Error(`HTTP error! status: ${response.status}`);
508    }
509    return response.json();
510  },
511};
512"#;
513
514    write_file_safe(output_path, http_client_content)?;
515
516    Ok(())
517}
518
519fn format_typescript_code(code: &str) -> String {
520    // Basic formatting: remove extra blank lines while preserving indentation
521    let lines: Vec<&str> = code.lines().collect();
522    let mut formatted = Vec::new();
523    let mut last_was_empty = false;
524
525    for line in lines {
526        if line.trim().is_empty() {
527            if !last_was_empty && !formatted.is_empty() {
528                formatted.push(String::new());
529                last_was_empty = true;
530            }
531            continue;
532        }
533        last_was_empty = false;
534        formatted.push(line.to_string());
535    }
536
537    // Remove trailing empty lines
538    while formatted.last().map(|s| s.is_empty()).unwrap_or(false) {
539        formatted.pop();
540    }
541
542    formatted.join("\n")
543}
544
545pub fn write_file_safe(path: &Path, content: &str) -> Result<()> {
546    write_file_with_backup(path, content, false, false)
547}
548
549pub fn write_file_with_backup(path: &Path, content: &str, backup: bool, force: bool) -> Result<()> {
550    // Check if file exists and content is different
551    let file_exists = path.exists();
552    let should_write = if file_exists {
553        if let Ok(existing_content) = std::fs::read_to_string(path) {
554            existing_content != content
555        } else {
556            true
557        }
558    } else {
559        true
560    };
561
562    if !should_write {
563        // Content is the same, skip writing
564        return Ok(());
565    }
566
567    // Create backup if requested and file exists
568    if backup && file_exists {
569        create_backup(path)?;
570    }
571
572    // Check for conflicts (user modifications) if not forcing
573    if !force && file_exists {
574        if let Ok(metadata) = load_file_metadata(path) {
575            let current_hash = compute_content_hash(content);
576            let file_hash = compute_file_hash(path)?;
577            if metadata.hash != current_hash && metadata.hash != file_hash {
578                // File was modified by user
579                return Err(FileSystemError::FileModifiedByUser {
580                    path: path.display().to_string(),
581                }
582                .into());
583            }
584        }
585    }
586
587    // Write the file
588    std::fs::write(path, content).map_err(|e| FileSystemError::WriteFileFailed {
589        path: path.display().to_string(),
590        source: e,
591    })?;
592
593    // Save metadata
594    save_file_metadata(path, content)?;
595
596    Ok(())
597}
598
599fn create_backup(path: &Path) -> Result<()> {
600    use std::collections::hash_map::DefaultHasher;
601    use std::hash::{Hash, Hasher};
602    use std::time::{SystemTime, UNIX_EPOCH};
603
604    let timestamp = SystemTime::now()
605        .duration_since(UNIX_EPOCH)
606        .unwrap()
607        .as_secs();
608
609    let backup_dir = PathBuf::from(format!(".vika-backup/{}", timestamp));
610    std::fs::create_dir_all(&backup_dir).map_err(|e| FileSystemError::CreateDirectoryFailed {
611        path: backup_dir.display().to_string(),
612        source: e,
613    })?;
614
615    // Determine backup path
616    let backup_path = if path.is_absolute() {
617        // For absolute paths (e.g., from temp directories in tests),
618        // use a hash-based filename to avoid very long paths
619        let path_str = path.display().to_string();
620        let mut hasher = DefaultHasher::new();
621        path_str.hash(&mut hasher);
622        let hash = format!("{:x}", hasher.finish());
623        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
624        backup_dir.join(format!("{}_{}", hash, filename))
625    } else {
626        // For relative paths, preserve directory structure
627        let relative_path = path.strip_prefix(".").unwrap_or(path);
628        backup_dir.join(relative_path)
629    };
630
631    if let Some(parent) = backup_path.parent() {
632        std::fs::create_dir_all(parent).map_err(|e| FileSystemError::CreateDirectoryFailed {
633            path: parent.display().to_string(),
634            source: e,
635        })?;
636    }
637
638    std::fs::copy(path, &backup_path).map_err(|e| FileSystemError::WriteFileFailed {
639        path: backup_path.display().to_string(),
640        source: e,
641    })?;
642
643    Ok(())
644}
645
646#[derive(Clone, serde::Serialize, serde::Deserialize)]
647struct FileMetadata {
648    hash: String,
649    generated_at: u64,
650    generated_by: String,
651}
652
653fn compute_content_hash(content: &str) -> String {
654    let mut hasher = DefaultHasher::new();
655    content.hash(&mut hasher);
656    format!("{:x}", hasher.finish())
657}
658
659fn compute_file_hash(path: &Path) -> Result<String> {
660    let content = std::fs::read_to_string(path).map_err(|e| FileSystemError::ReadFileFailed {
661        path: path.display().to_string(),
662        source: e,
663    })?;
664    Ok(compute_content_hash(&content))
665}
666
667fn save_file_metadata(path: &Path, content: &str) -> Result<()> {
668    let metadata_dir = PathBuf::from(".vika-cache");
669    std::fs::create_dir_all(&metadata_dir).map_err(|e| FileSystemError::CreateDirectoryFailed {
670        path: metadata_dir.display().to_string(),
671        source: e,
672    })?;
673
674    let metadata_file = metadata_dir.join("file-metadata.json");
675    let mut metadata_map: std::collections::HashMap<String, FileMetadata> =
676        if metadata_file.exists() {
677            let content = std::fs::read_to_string(&metadata_file).map_err(|e| {
678                FileSystemError::ReadFileFailed {
679                    path: metadata_file.display().to_string(),
680                    source: e,
681                }
682            })?;
683            serde_json::from_str(&content).unwrap_or_default()
684        } else {
685            std::collections::HashMap::new()
686        };
687
688    let hash = compute_content_hash(content);
689    let generated_at = SystemTime::now()
690        .duration_since(std::time::UNIX_EPOCH)
691        .unwrap()
692        .as_secs();
693
694    metadata_map.insert(
695        path.display().to_string(),
696        FileMetadata {
697            hash,
698            generated_at,
699            generated_by: "vika-cli".to_string(),
700        },
701    );
702
703    let json = serde_json::to_string_pretty(&metadata_map).map_err(|e| {
704        FileSystemError::WriteFileFailed {
705            path: metadata_file.display().to_string(),
706            source: std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)),
707        }
708    })?;
709
710    std::fs::write(&metadata_file, json).map_err(|e| FileSystemError::WriteFileFailed {
711        path: metadata_file.display().to_string(),
712        source: e,
713    })?;
714
715    Ok(())
716}
717
718fn load_file_metadata(path: &Path) -> Result<FileMetadata> {
719    let metadata_file = PathBuf::from(".vika-cache/file-metadata.json");
720    if !metadata_file.exists() {
721        return Err(FileSystemError::FileNotFound {
722            path: metadata_file.display().to_string(),
723        }
724        .into());
725    }
726
727    let content =
728        std::fs::read_to_string(&metadata_file).map_err(|e| FileSystemError::ReadFileFailed {
729            path: metadata_file.display().to_string(),
730            source: e,
731        })?;
732
733    let metadata_map: std::collections::HashMap<String, FileMetadata> =
734        serde_json::from_str(&content).map_err(|e| FileSystemError::ReadFileFailed {
735            path: metadata_file.display().to_string(),
736            source: std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)),
737        })?;
738
739    metadata_map
740        .get(&path.display().to_string())
741        .cloned()
742        .ok_or_else(|| {
743            FileSystemError::FileNotFound {
744                path: path.display().to_string(),
745            }
746            .into()
747        })
748}