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 if !types.is_empty() {
45 let mut seen_type_names = std::collections::HashSet::new();
48 let mut deduplicated_types = Vec::new();
49 for t in types {
50 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 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 let needs_common_import = types_content_raw.contains("Common.");
84 let common_import = if needs_common_import {
85 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 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 let needs_common_import = zod_content_raw.contains("Common.");
111 let common_import = if needs_common_import {
112 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 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 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 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 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 if before_from.contains("import type {") {
208 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 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 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 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 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 in_jsdoc = false;
257 }
258 } else if line.trim().starts_with("export const ") {
259 let trimmed = line.trim();
262 if trimmed.len() > 13 {
263 let after_export_const = &trimmed[13..];
264 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 jsdoc_lines.clear();
275 break;
276 }
277 seen_functions.insert(name);
278 }
279 }
280 in_function = true;
281 func_lines.extend(jsdoc_lines.drain(..));
283 func_lines.push(line);
284 } else if in_function {
285 func_lines.push(line);
286 if line.trim() == "};" {
288 break;
289 }
290 }
291 }
293
294 if !func_lines.is_empty() && function_name.is_some() {
295 function_bodies.push(func_lines.join("\n"));
296 }
297 }
298
299 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 let deduped: std::collections::HashSet<String> =
306 other_imports.iter().cloned().collect();
307 imports_vec.extend(deduped.into_iter());
308 } else {
309 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_imports.push(item.clone());
319 } else {
320 default_imports.push(item.clone());
322 }
323 }
324
325 namespace_imports.sort();
327 for ns_import in namespace_imports {
328 imports_vec.push(format!("{};", ns_import));
329 }
330
331 default_imports.sort();
333 for default_import in default_imports {
334 imports_vec.push(format!("{};", default_import));
335 }
336
337 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 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 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 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 return Ok(());
565 }
566
567 if backup && file_exists {
569 create_backup(path)?;
570 }
571
572 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 return Err(FileSystemError::FileModifiedByUser {
580 path: path.display().to_string(),
581 }
582 .into());
583 }
584 }
585 }
586
587 std::fs::write(path, content).map_err(|e| FileSystemError::WriteFileFailed {
589 path: path.display().to_string(),
590 source: e,
591 })?;
592
593 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 let backup_path = if path.is_absolute() {
617 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 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}