1use crate::generate_config::{BarrelType, OutputConfig};
9use crate::openapi::spec::OpenApiSpec;
10use chrono::Utc;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone)]
16pub struct GeneratedFile {
17 pub path: PathBuf,
19 pub content: String,
21 pub extension: String,
23 pub exportable: bool,
25}
26
27pub struct BarrelGenerator;
29
30impl BarrelGenerator {
31 pub fn generate_barrel_files(
41 output_dir: &Path,
42 files: &[GeneratedFile],
43 barrel_type: BarrelType,
44 ) -> Result<Vec<(PathBuf, String)>, crate::Error> {
45 match barrel_type {
46 BarrelType::None => Ok(vec![]),
47 BarrelType::Index => Self::generate_index_file(output_dir, files),
48 BarrelType::Barrel => Self::generate_barrel_structure(output_dir, files),
49 }
50 }
51
52 fn generate_index_file(
54 output_dir: &Path,
55 files: &[GeneratedFile],
56 ) -> Result<Vec<(PathBuf, String)>, crate::Error> {
57 let mut exports = Vec::new();
58
59 for file in files {
61 if !file.exportable {
62 continue;
63 }
64
65 let rel_path = file.path.clone();
67 let import_path = if rel_path.extension().is_some() {
68 rel_path.with_extension("")
70 } else {
71 rel_path
72 };
73
74 let import_str = import_path
76 .to_string_lossy()
77 .replace('\\', "/")
78 .trim_start_matches("./")
79 .to_string();
80
81 let export = match file.extension.as_str() {
83 "ts" | "tsx" => {
84 format!("export * from './{}';", import_str)
85 }
86 "js" | "jsx" | "mjs" => {
87 format!("export * from './{}';", import_str)
89 }
90 _ => continue, };
92
93 exports.push(export);
94 }
95
96 exports.sort();
98
99 let index_content = if exports.is_empty() {
101 "// Generated by MockForge\n// No exportable files found\n".to_string()
102 } else {
103 format!(
104 "// Generated by MockForge\n// Barrel file - exports all generated modules\n\n{}\n",
105 exports.join("\n")
106 )
107 };
108
109 let index_path = output_dir.join("index.ts");
110 Ok(vec![(index_path, index_content)])
111 }
112
113 fn generate_barrel_structure(
115 output_dir: &Path,
116 files: &[GeneratedFile],
117 ) -> Result<Vec<(PathBuf, String)>, crate::Error> {
118 let mut dir_exports: HashMap<PathBuf, Vec<(String, PathBuf)>> = HashMap::new();
120
121 for file in files {
122 if !file.exportable {
123 continue;
124 }
125
126 let parent = file.path.parent().unwrap_or(Path::new("."));
127
128 let file_stem = file.path.file_stem().unwrap_or_default();
131 let import_str = file_stem.to_string_lossy().to_string();
132
133 dir_exports
134 .entry(parent.to_path_buf())
135 .or_insert_with(Vec::new)
136 .push((format!("export * from './{}';", import_str), file.path.clone()));
137 }
138
139 let mut barrel_files = Vec::new();
140
141 for (dir, exports) in dir_exports {
143 let mut export_lines: Vec<String> = exports.iter().map(|(e, _)| e.clone()).collect();
144 export_lines.sort();
145
146 let index_content = if export_lines.is_empty() {
147 continue;
148 } else {
149 format!(
150 "// Generated by MockForge\n// Barrel file for directory: {}\n\n{}\n",
151 dir.display(),
152 export_lines.join("\n")
153 )
154 };
155
156 let index_path = if dir == Path::new(".") {
157 output_dir.join("index.ts")
158 } else {
159 output_dir.join(dir).join("index.ts")
160 };
161
162 barrel_files.push((index_path, index_content));
163 }
164
165 Ok(barrel_files)
166 }
167}
168
169pub fn apply_banner(content: &str, banner_template: &str, source_path: Option<&Path>) -> String {
171 let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
173 let generator = "MockForge";
174 let source = source_path
175 .map(|p| p.display().to_string())
176 .unwrap_or_else(|| "unknown".to_string());
177
178 let banner = banner_template
179 .replace("{{timestamp}}", ×tamp)
180 .replace("{{source}}", &source)
181 .replace("{{generator}}", generator);
182
183 let comment_style = if content.trim_start().starts_with("//")
185 || content.trim_start().starts_with("/*")
186 || content.trim_start().starts_with("*")
187 {
188 "line"
190 } else if content.trim_start().starts_with("#") {
191 "hash"
193 } else {
194 if content.contains("export") || content.contains("import") {
197 "line"
198 } else {
199 "block"
200 }
201 };
202
203 let formatted_banner = match comment_style {
205 "hash" => {
206 format!("# {}\n", banner.replace('\n', "\n# "))
208 }
209 "line" => {
210 format!("// {}\n", banner.replace('\n', "\n// "))
212 }
213 _ => {
214 format!("/*\n * {}\n */\n", banner.replace('\n', "\n * "))
216 }
217 };
218
219 format!("{}\n{}", formatted_banner, content)
220}
221
222pub fn apply_extension(file_path: &Path, extension: Option<&str>) -> PathBuf {
224 match extension {
225 Some(ext) => {
226 file_path.with_extension(ext)
228 }
229 None => file_path.to_path_buf(),
230 }
231}
232
233pub fn apply_file_naming_template(template: &str, context: &HashMap<&str, &str>) -> String {
235 let mut result = template.to_string();
236
237 for (key, value) in context {
239 let placeholder = format!("{{{{{}}}}}", key);
240 result = result.replace(&placeholder, value);
241 }
242
243 result
244}
245
246#[derive(Debug, Clone)]
251pub struct FileNamingContext {
252 context_map: HashMap<String, HashMap<String, String>>,
255 defaults: HashMap<String, String>,
258}
259
260impl FileNamingContext {
261 pub fn new() -> Self {
263 let mut defaults = HashMap::new();
264 defaults.insert("tag".to_string(), "api".to_string());
265 defaults.insert("operation".to_string(), String::new());
266 defaults.insert("path".to_string(), String::new());
267
268 Self {
269 context_map: HashMap::new(),
270 defaults,
271 }
272 }
273
274 pub fn get_context_for_name(&self, name: &str) -> HashMap<&str, &str> {
276 if let Some(context) = self.context_map.get(name) {
277 context.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
278 } else {
279 self.defaults.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
281 }
282 }
283}
284
285impl Default for FileNamingContext {
286 fn default() -> Self {
287 Self::new()
288 }
289}
290
291pub fn build_file_naming_context(spec: &OpenApiSpec) -> FileNamingContext {
293 let mut context = FileNamingContext::new();
294 let mut context_map = HashMap::new();
295
296 let all_paths = spec.all_paths_and_operations();
298
299 for (path, operations) in all_paths {
300 for (method, operation) in operations {
301 let name = operation.operation_id.clone().unwrap_or_else(|| {
303 let path_name =
305 path.trim_matches('/').replace('/', "_").replace('{', "").replace('}', "");
306 format!("{}_{}", method.to_lowercase(), path_name)
307 });
308
309 let mut op_context = HashMap::new();
311
312 let tag = operation.tags.first().cloned().unwrap_or_else(|| "api".to_string());
314 op_context.insert("tag".to_string(), tag);
315
316 op_context.insert("operation".to_string(), method.to_lowercase());
318
319 op_context.insert("path".to_string(), path.clone());
321
322 op_context.insert("name".to_string(), name.clone());
324
325 context_map.insert(name.clone(), op_context.clone());
327
328 let simple_name = name.to_lowercase().replace('-', "_").replace(' ', "_");
330 if simple_name != name {
331 context_map.insert(simple_name, op_context);
332 }
333 }
334 }
335
336 if let Some(schemas) = spec.schemas() {
338 for (schema_name, _) in schemas {
339 let mut schema_context = HashMap::new();
340 schema_context.insert("name".to_string(), schema_name.clone());
341 schema_context.insert("tag".to_string(), "schemas".to_string());
342 schema_context.insert("operation".to_string(), String::new());
343 schema_context.insert("path".to_string(), String::new());
344
345 context_map.insert(schema_name.clone(), schema_context);
346 }
347 }
348
349 context.context_map = context_map;
350 context
351}
352
353pub fn process_generated_file(
355 mut file: GeneratedFile,
356 config: &OutputConfig,
357 source_path: Option<&Path>,
358 naming_context: Option<&FileNamingContext>,
359) -> GeneratedFile {
360 if let Some(template) = &config.file_naming_template {
362 let parent = file.path.parent().unwrap_or(Path::new("."));
364 let old_stem = file.path.file_stem().unwrap_or_default().to_string_lossy().to_string();
365
366 let context_values: HashMap<&str, &str> = if let Some(ctx) = naming_context {
368 let mut values = ctx.get_context_for_name(&old_stem);
370 if !values.contains_key("name") {
372 values.insert("name", &old_stem);
373 }
374 values
375 } else {
376 let mut fallback = HashMap::new();
378 fallback.insert("name", old_stem.as_str());
379 fallback.insert("tag", "api");
380 fallback.insert("operation", "");
381 fallback.insert("path", "");
382 fallback
383 };
384
385 let new_name = apply_file_naming_template(template, &context_values);
386
387 let ext = file.path.extension().and_then(|e| e.to_str()).unwrap_or("");
389 let new_filename = if ext.is_empty() {
390 new_name
391 } else {
392 format!("{}.{}", new_name, ext)
393 };
394 file.path = parent.join(new_filename);
395 }
396
397 if let Some(ext) = &config.extension {
399 file.path = apply_extension(&file.path, Some(ext));
400 file.extension = ext.clone();
401 }
402
403 if let Some(banner_template) = &config.banner {
405 file.content = apply_banner(&file.content, banner_template, source_path);
406 }
407
408 file
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn test_apply_banner() {
417 let content = "export const test = 1;";
418 let banner = "Generated by {{generator}}\nSource: {{source}}";
419 let source = Some(Path::new("api.yaml"));
420
421 let result = apply_banner(content, banner, source);
422 assert!(result.contains("MockForge"));
423 assert!(result.contains("api.yaml"));
424 assert!(result.contains("export const test"));
425 }
426
427 #[test]
428 fn test_apply_extension() {
429 let path = Path::new("output/file.js");
430 let new_path = apply_extension(path, Some("ts"));
431 assert_eq!(new_path, PathBuf::from("output/file.ts"));
432 }
433
434 #[test]
435 fn test_apply_file_naming_template() {
436 let template = "{{name}}_{{tag}}";
437 let mut context = HashMap::new();
438 context.insert("name", "user");
439 context.insert("tag", "api");
440
441 let result = apply_file_naming_template(template, &context);
442 assert_eq!(result, "user_api");
443 }
444
445 #[test]
446 fn test_generate_index_file() {
447 let output_dir = Path::new("/tmp/test");
448 let files = vec![
449 GeneratedFile {
450 path: PathBuf::from("types.ts"),
451 content: "export type User = {};".to_string(),
452 extension: "ts".to_string(),
453 exportable: true,
454 },
455 GeneratedFile {
456 path: PathBuf::from("client.ts"),
457 content: "export const client = {};".to_string(),
458 extension: "ts".to_string(),
459 exportable: true,
460 },
461 ];
462
463 let result = BarrelGenerator::generate_index_file(output_dir, &files).unwrap();
464 assert_eq!(result.len(), 1);
465 assert!(result[0].0.ends_with("index.ts"));
466 assert!(result[0].1.contains("export * from './types'"));
467 assert!(result[0].1.contains("export * from './client'"));
468 }
469}