1use crate::error::{FileSystemError, Result};
2use crate::generator::api_client::ApiFunction;
3use crate::generator::ts_typings::TypeScriptType;
4use crate::generator::zod_schema::ZodSchema;
5use std::collections::hash_map::DefaultHasher;
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10pub fn ensure_directory(path: &Path) -> Result<()> {
11 if !path.exists() {
12 std::fs::create_dir_all(path).map_err(|e| FileSystemError::CreateDirectoryFailed {
13 path: path.display().to_string(),
14 source: e,
15 })?;
16 }
17 Ok(())
18}
19
20pub fn write_schemas(
21 output_dir: &Path,
22 module_name: &str,
23 types: &[TypeScriptType],
24 zod_schemas: &[ZodSchema],
25) -> Result<Vec<PathBuf>> {
26 write_schemas_with_options(output_dir, module_name, types, zod_schemas, false, false)
27}
28
29pub fn write_schemas_with_options(
30 output_dir: &Path,
31 module_name: &str,
32 types: &[TypeScriptType],
33 zod_schemas: &[ZodSchema],
34 backup: bool,
35 force: bool,
36) -> Result<Vec<PathBuf>> {
37 let module_dir = output_dir.join(module_name);
38 ensure_directory(&module_dir)?;
39
40 let mut written_files = Vec::new();
41
42 if !types.is_empty() {
44 let types_content = format_typescript_code(
45 &types
46 .iter()
47 .map(|t| t.content.clone())
48 .collect::<Vec<_>>()
49 .join("\n\n")
50 .to_string(),
51 );
52
53 let types_file = module_dir.join("types.ts");
54 write_file_with_backup(&types_file, &types_content, backup, force)?;
55 written_files.push(types_file);
56 }
57
58 if !zod_schemas.is_empty() {
60 let zod_content = format_typescript_code(&format!(
61 "import {{ z }} from \"zod\";\n\n{}",
62 zod_schemas
63 .iter()
64 .map(|z| z.content.clone())
65 .collect::<Vec<_>>()
66 .join("\n\n")
67 ));
68
69 let zod_file = module_dir.join("schemas.ts");
70 write_file_with_backup(&zod_file, &zod_content, backup, force)?;
71 written_files.push(zod_file);
72 }
73
74 let mut index_exports = Vec::new();
76 if !types.is_empty() {
77 index_exports.push("export * from \"./types\";".to_string());
78 }
79 if !zod_schemas.is_empty() {
80 index_exports.push("export * from \"./schemas\";".to_string());
81 }
82
83 if !index_exports.is_empty() {
84 let index_content = format_typescript_code(&(index_exports.join("\n") + "\n"));
88 let index_file = module_dir.join("index.ts");
89 write_file_with_backup(&index_file, &index_content, backup, force)?;
90 written_files.push(index_file);
91 }
92
93 Ok(written_files)
94}
95
96pub fn write_api_client(
97 output_dir: &Path,
98 module_name: &str,
99 functions: &[ApiFunction],
100) -> Result<Vec<PathBuf>> {
101 write_api_client_with_options(output_dir, module_name, functions, false, false)
102}
103
104pub fn write_api_client_with_options(
105 output_dir: &Path,
106 module_name: &str,
107 functions: &[ApiFunction],
108 backup: bool,
109 force: bool,
110) -> Result<Vec<PathBuf>> {
111 let module_dir = output_dir.join(module_name);
112 ensure_directory(&module_dir)?;
113
114 let mut written_files = Vec::new();
115
116 if !functions.is_empty() {
117 let mut all_imports: std::collections::HashSet<String> = std::collections::HashSet::new();
119 let mut function_bodies = Vec::new();
120
121 for func in functions {
122 let lines: Vec<&str> = func.content.lines().collect();
123 let mut func_lines = Vec::new();
124 let mut in_function = false;
125
126 for line in lines {
127 if line.trim().starts_with("import ") {
128 all_imports.insert(line.trim().to_string());
129 } else if line.trim().starts_with("export const ") {
130 in_function = true;
131 func_lines.push(line);
132 } else if in_function {
133 func_lines.push(line);
134 }
135 }
136
137 if !func_lines.is_empty() {
138 function_bodies.push(func_lines.join("\n"));
139 }
140 }
141
142 let imports_vec: Vec<String> = all_imports.iter().cloned().collect();
144 let imports_str = imports_vec.join("\n");
145 let functions_str = function_bodies.join("\n\n");
146 let combined_content = if !imports_str.is_empty() {
147 format!("{}\n\n{}", imports_str, functions_str)
148 } else {
149 functions_str
150 };
151
152 let functions_content = format_typescript_code(&combined_content);
153
154 let api_file = module_dir.join("index.ts");
155 write_file_with_backup(&api_file, &functions_content, backup, force)?;
156 written_files.push(api_file);
157 }
158
159 Ok(written_files)
160}
161
162pub fn write_http_client_template(output_path: &Path) -> Result<()> {
163 ensure_directory(output_path.parent().unwrap_or(Path::new(".")))?;
164
165 let http_client_content = r#"export const http = {
166 async get<T = any>(url: string, options: RequestInit = {}): Promise<T> {
167 const response = await fetch(url, {
168 ...options,
169 method: "GET",
170 });
171 if (!response.ok) {
172 throw new Error(`HTTP error! status: ${response.status}`);
173 }
174 return response.json();
175 },
176
177 async post<T = any>(url: string, body?: any, options: RequestInit = {}): Promise<T> {
178 const response = await fetch(url, {
179 ...options,
180 method: "POST",
181 headers: {
182 "Content-Type": "application/json",
183 ...(options.headers || {}),
184 },
185 body: body ? JSON.stringify(body) : undefined,
186 });
187 if (!response.ok) {
188 throw new Error(`HTTP error! status: ${response.status}`);
189 }
190 return response.json();
191 },
192
193 async put<T = any>(url: string, body?: any, options: RequestInit = {}): Promise<T> {
194 const response = await fetch(url, {
195 ...options,
196 method: "PUT",
197 headers: {
198 "Content-Type": "application/json",
199 ...(options.headers || {}),
200 },
201 body: body ? JSON.stringify(body) : undefined,
202 });
203 if (!response.ok) {
204 throw new Error(`HTTP error! status: ${response.status}`);
205 }
206 return response.json();
207 },
208
209 async delete<T = any>(url: string, options: RequestInit = {}): Promise<T> {
210 const response = await fetch(url, {
211 ...options,
212 method: "DELETE",
213 });
214 if (!response.ok) {
215 throw new Error(`HTTP error! status: ${response.status}`);
216 }
217 return response.json();
218 },
219
220 async patch<T = any>(url: string, body?: any, options: RequestInit = {}): Promise<T> {
221 const response = await fetch(url, {
222 ...options,
223 method: "PATCH",
224 headers: {
225 "Content-Type": "application/json",
226 ...(options.headers || {}),
227 },
228 body: body ? JSON.stringify(body) : undefined,
229 });
230 if (!response.ok) {
231 throw new Error(`HTTP error! status: ${response.status}`);
232 }
233 return response.json();
234 },
235};
236"#;
237
238 write_file_safe(output_path, http_client_content)?;
239
240 Ok(())
241}
242
243fn format_typescript_code(code: &str) -> String {
244 let lines: Vec<&str> = code.lines().collect();
246 let mut formatted = Vec::new();
247 let mut last_was_empty = false;
248
249 for line in lines {
250 let trimmed = line.trim();
251 if trimmed.is_empty() {
252 if !last_was_empty && !formatted.is_empty() {
253 formatted.push(String::new());
254 last_was_empty = true;
255 }
256 continue;
257 }
258 last_was_empty = false;
259 formatted.push(trimmed.to_string());
260 }
261
262 while formatted.last().map(|s| s.is_empty()).unwrap_or(false) {
264 formatted.pop();
265 }
266
267 formatted.join("\n")
268}
269
270pub fn write_file_safe(path: &Path, content: &str) -> Result<()> {
271 write_file_with_backup(path, content, false, false)
272}
273
274pub fn write_file_with_backup(path: &Path, content: &str, backup: bool, force: bool) -> Result<()> {
275 let file_exists = path.exists();
277 let should_write = if file_exists {
278 if let Ok(existing_content) = std::fs::read_to_string(path) {
279 existing_content != content
280 } else {
281 true
282 }
283 } else {
284 true
285 };
286
287 if !should_write {
288 return Ok(());
290 }
291
292 if backup && file_exists {
294 create_backup(path)?;
295 }
296
297 if !force && file_exists {
299 if let Ok(metadata) = load_file_metadata(path) {
300 let current_hash = compute_content_hash(content);
301 let file_hash = compute_file_hash(path)?;
302 if metadata.hash != current_hash && metadata.hash != file_hash {
303 return Err(FileSystemError::FileModifiedByUser {
305 path: path.display().to_string(),
306 }
307 .into());
308 }
309 }
310 }
311
312 std::fs::write(path, content).map_err(|e| FileSystemError::WriteFileFailed {
314 path: path.display().to_string(),
315 source: e,
316 })?;
317
318 save_file_metadata(path, content)?;
320
321 Ok(())
322}
323
324fn create_backup(path: &Path) -> Result<()> {
325 use std::collections::hash_map::DefaultHasher;
326 use std::hash::{Hash, Hasher};
327 use std::time::{SystemTime, UNIX_EPOCH};
328
329 let timestamp = SystemTime::now()
330 .duration_since(UNIX_EPOCH)
331 .unwrap()
332 .as_secs();
333
334 let backup_dir = PathBuf::from(format!(".vika-backup/{}", timestamp));
335 std::fs::create_dir_all(&backup_dir).map_err(|e| FileSystemError::CreateDirectoryFailed {
336 path: backup_dir.display().to_string(),
337 source: e,
338 })?;
339
340 let backup_path = if path.is_absolute() {
342 let path_str = path.display().to_string();
345 let mut hasher = DefaultHasher::new();
346 path_str.hash(&mut hasher);
347 let hash = format!("{:x}", hasher.finish());
348 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
349 backup_dir.join(format!("{}_{}", hash, filename))
350 } else {
351 let relative_path = path.strip_prefix(".").unwrap_or(path);
353 backup_dir.join(relative_path)
354 };
355
356 if let Some(parent) = backup_path.parent() {
357 std::fs::create_dir_all(parent).map_err(|e| FileSystemError::CreateDirectoryFailed {
358 path: parent.display().to_string(),
359 source: e,
360 })?;
361 }
362
363 std::fs::copy(path, &backup_path).map_err(|e| FileSystemError::WriteFileFailed {
364 path: backup_path.display().to_string(),
365 source: e,
366 })?;
367
368 Ok(())
369}
370
371#[derive(Clone, serde::Serialize, serde::Deserialize)]
372struct FileMetadata {
373 hash: String,
374 generated_at: u64,
375 generated_by: String,
376}
377
378fn compute_content_hash(content: &str) -> String {
379 let mut hasher = DefaultHasher::new();
380 content.hash(&mut hasher);
381 format!("{:x}", hasher.finish())
382}
383
384fn compute_file_hash(path: &Path) -> Result<String> {
385 let content = std::fs::read_to_string(path).map_err(|e| FileSystemError::ReadFileFailed {
386 path: path.display().to_string(),
387 source: e,
388 })?;
389 Ok(compute_content_hash(&content))
390}
391
392fn save_file_metadata(path: &Path, content: &str) -> Result<()> {
393 let metadata_dir = PathBuf::from(".vika-cache");
394 std::fs::create_dir_all(&metadata_dir).map_err(|e| FileSystemError::CreateDirectoryFailed {
395 path: metadata_dir.display().to_string(),
396 source: e,
397 })?;
398
399 let metadata_file = metadata_dir.join("file-metadata.json");
400 let mut metadata_map: std::collections::HashMap<String, FileMetadata> =
401 if metadata_file.exists() {
402 let content = std::fs::read_to_string(&metadata_file).map_err(|e| {
403 FileSystemError::ReadFileFailed {
404 path: metadata_file.display().to_string(),
405 source: e,
406 }
407 })?;
408 serde_json::from_str(&content).unwrap_or_default()
409 } else {
410 std::collections::HashMap::new()
411 };
412
413 let hash = compute_content_hash(content);
414 let generated_at = SystemTime::now()
415 .duration_since(std::time::UNIX_EPOCH)
416 .unwrap()
417 .as_secs();
418
419 metadata_map.insert(
420 path.display().to_string(),
421 FileMetadata {
422 hash,
423 generated_at,
424 generated_by: "vika-cli".to_string(),
425 },
426 );
427
428 let json = serde_json::to_string_pretty(&metadata_map).map_err(|e| {
429 FileSystemError::WriteFileFailed {
430 path: metadata_file.display().to_string(),
431 source: std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)),
432 }
433 })?;
434
435 std::fs::write(&metadata_file, json).map_err(|e| FileSystemError::WriteFileFailed {
436 path: metadata_file.display().to_string(),
437 source: e,
438 })?;
439
440 Ok(())
441}
442
443fn load_file_metadata(path: &Path) -> Result<FileMetadata> {
444 let metadata_file = PathBuf::from(".vika-cache/file-metadata.json");
445 if !metadata_file.exists() {
446 return Err(FileSystemError::FileNotFound {
447 path: metadata_file.display().to_string(),
448 }
449 .into());
450 }
451
452 let content =
453 std::fs::read_to_string(&metadata_file).map_err(|e| FileSystemError::ReadFileFailed {
454 path: metadata_file.display().to_string(),
455 source: e,
456 })?;
457
458 let metadata_map: std::collections::HashMap<String, FileMetadata> =
459 serde_json::from_str(&content).map_err(|e| FileSystemError::ReadFileFailed {
460 path: metadata_file.display().to_string(),
461 source: std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)),
462 })?;
463
464 metadata_map
465 .get(&path.display().to_string())
466 .cloned()
467 .ok_or_else(|| {
468 FileSystemError::FileNotFound {
469 path: path.display().to_string(),
470 }
471 .into()
472 })
473}