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::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    // Write TypeScript types
43    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    // Write Zod schemas
59    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    // Write index file with namespace export for better organization
75    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        // Write index file with regular exports
85        // Note: TypeScript namespaces cannot use export *, so we use regular exports
86        // and import as namespace in API clients for better organization
87        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        // Consolidate imports: extract all imports and deduplicate them
118        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        // Combine imports and function bodies
143        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    // Basic formatting: ensure consistent spacing and remove extra blank lines
245    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    // Remove trailing empty lines
263    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    // Check if file exists and content is different
276    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        // Content is the same, skip writing
289        return Ok(());
290    }
291
292    // Create backup if requested and file exists
293    if backup && file_exists {
294        create_backup(path)?;
295    }
296
297    // Check for conflicts (user modifications) if not forcing
298    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                // File was modified by user
304                return Err(FileSystemError::FileModifiedByUser {
305                    path: path.display().to_string(),
306                }
307                .into());
308            }
309        }
310    }
311
312    // Write the file
313    std::fs::write(path, content).map_err(|e| FileSystemError::WriteFileFailed {
314        path: path.display().to_string(),
315        source: e,
316    })?;
317
318    // Save metadata
319    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    // Determine backup path
341    let backup_path = if path.is_absolute() {
342        // For absolute paths (e.g., from temp directories in tests),
343        // use a hash-based filename to avoid very long paths
344        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        // For relative paths, preserve directory structure
352        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}