mockforge_core/
output_control.rs

1//! Output control utilities for MockForge generation
2//!
3//! This module provides functionality for:
4//! - Generating barrel/index files
5//! - Applying file extensions and banners
6//! - Customizing file naming patterns
7
8use crate::generate_config::{BarrelType, OutputConfig};
9use crate::openapi::spec::OpenApiSpec;
10use chrono::Utc;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14/// Represents a generated file with metadata
15#[derive(Debug, Clone)]
16pub struct GeneratedFile {
17    /// Relative path from output directory
18    pub path: PathBuf,
19    /// File content
20    pub content: String,
21    /// File extension (without dot)
22    pub extension: String,
23    /// Whether this file should be included in barrel exports
24    pub exportable: bool,
25}
26
27/// Generate barrel/index files for a directory structure
28pub struct BarrelGenerator;
29
30impl BarrelGenerator {
31    /// Generate barrel files based on the configuration
32    ///
33    /// # Arguments
34    /// * `output_dir` - Root output directory
35    /// * `files` - List of generated files
36    /// * `barrel_type` - Type of barrel files to generate
37    ///
38    /// # Returns
39    /// Vector of barrel file paths and contents
40    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    /// Generate a single index.ts file at the root
53    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        // Collect exportable files (TypeScript/JavaScript files)
60        for file in files {
61            if !file.exportable {
62                continue;
63            }
64
65            // Determine relative import path
66            let rel_path = file.path.clone();
67            let import_path = if rel_path.extension().is_some() {
68                // Remove extension for import
69                rel_path.with_extension("")
70            } else {
71                rel_path
72            };
73
74            // Convert to forward slashes for import paths
75            let import_str = import_path
76                .to_string_lossy()
77                .replace('\\', "/")
78                .trim_start_matches("./")
79                .to_string();
80
81            // Generate export statement based on extension
82            let export = match file.extension.as_str() {
83                "ts" | "tsx" => {
84                    format!("export * from './{}';", import_str)
85                }
86                "js" | "jsx" | "mjs" => {
87                    // For JS files, may need default exports or named exports
88                    format!("export * from './{}';", import_str)
89                }
90                _ => continue, // Skip non-exportable files
91            };
92
93            exports.push(export);
94        }
95
96        // Sort exports for consistent output
97        exports.sort();
98
99        // Generate index.ts content
100        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    /// Generate barrel structure (index files at multiple directory levels)
114    fn generate_barrel_structure(
115        output_dir: &Path,
116        files: &[GeneratedFile],
117    ) -> Result<Vec<(PathBuf, String)>, crate::Error> {
118        // Group files by directory
119        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            // Create import path relative to parent directory (not absolute from root)
129            // If file is "api/types.ts" and parent is "api", import should be "./types"
130            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        // Generate index.ts for each directory with exports
142        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
169/// Apply banner to file content
170pub fn apply_banner(content: &str, banner_template: &str, source_path: Option<&Path>) -> String {
171    // Replace template placeholders with actual values
172    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}}", &timestamp)
180        .replace("{{source}}", &source)
181        .replace("{{generator}}", generator);
182
183    // Detect file type from content to determine comment style
184    let comment_style = if content.trim_start().starts_with("//")
185        || content.trim_start().starts_with("/*")
186        || content.trim_start().starts_with("*")
187    {
188        // Already has comments - assume line comments
189        "line"
190    } else if content.trim_start().starts_with("#") {
191        // Script file (shell, Python, etc.)
192        "hash"
193    } else {
194        // Default: try to infer from common patterns
195        // Check if it looks like TypeScript/JavaScript
196        if content.contains("export") || content.contains("import") {
197            "line"
198        } else {
199            "block"
200        }
201    };
202
203    // Format banner according to detected style
204    let formatted_banner = match comment_style {
205        "hash" => {
206            // Hash-style comments (#)
207            format!("# {}\n", banner.replace('\n', "\n# "))
208        }
209        "line" => {
210            // Line-style comments (//)
211            format!("// {}\n", banner.replace('\n', "\n// "))
212        }
213        _ => {
214            // Block-style comments (/* */)
215            format!("/*\n * {}\n */\n", banner.replace('\n', "\n * "))
216        }
217    };
218
219    format!("{}\n{}", formatted_banner, content)
220}
221
222/// Apply file extension override
223pub fn apply_extension(file_path: &Path, extension: Option<&str>) -> PathBuf {
224    match extension {
225        Some(ext) => {
226            // Remove any existing extension and add new one
227            file_path.with_extension(ext)
228        }
229        None => file_path.to_path_buf(),
230    }
231}
232
233/// Apply file naming template
234pub fn apply_file_naming_template(template: &str, context: &HashMap<&str, &str>) -> String {
235    let mut result = template.to_string();
236
237    // Replace all placeholders
238    for (key, value) in context {
239        let placeholder = format!("{{{{{}}}}}", key);
240        result = result.replace(&placeholder, value);
241    }
242
243    result
244}
245
246/// Context for file naming templates extracted from OpenAPI spec
247#[derive(Debug, Clone)]
248pub struct FileNamingContext {
249    /// Mapping of file/operation names to their context values
250    context_map: HashMap<String, HashMap<String, String>>,
251    /// Default context values when no specific mapping exists
252    defaults: HashMap<String, String>,
253}
254
255impl FileNamingContext {
256    /// Create a new empty context with defaults
257    pub fn new() -> Self {
258        let mut defaults = HashMap::new();
259        defaults.insert("tag".to_string(), "api".to_string());
260        defaults.insert("operation".to_string(), String::new());
261        defaults.insert("path".to_string(), String::new());
262
263        Self {
264            context_map: HashMap::new(),
265            defaults,
266        }
267    }
268
269    /// Get context values for a given name
270    pub fn get_context_for_name(&self, name: &str) -> HashMap<&str, &str> {
271        if let Some(context) = self.context_map.get(name) {
272            context.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
273        } else {
274            // Return defaults
275            self.defaults.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
276        }
277    }
278}
279
280impl Default for FileNamingContext {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286/// Build file naming context from OpenAPI specification
287pub fn build_file_naming_context(spec: &OpenApiSpec) -> FileNamingContext {
288    let mut context = FileNamingContext::new();
289    let mut context_map = HashMap::new();
290
291    // Extract all paths and operations
292    let all_paths = spec.all_paths_and_operations();
293
294    for (path, operations) in all_paths {
295        for (method, operation) in operations {
296            // Build name from operation ID, or generate one from method + path
297            let name = operation.operation_id.clone().unwrap_or_else(|| {
298                // Generate name from method and path
299                let path_name =
300                    path.trim_matches('/').replace('/', "_").replace('{', "").replace('}', "");
301                format!("{}_{}", method.to_lowercase(), path_name)
302            });
303
304            // Build context for this operation
305            let mut op_context = HashMap::new();
306
307            // Get primary tag (first tag, or empty)
308            let tag = operation.tags.first().cloned().unwrap_or_else(|| "api".to_string());
309            op_context.insert("tag".to_string(), tag);
310
311            // Operation method
312            op_context.insert("operation".to_string(), method.to_lowercase());
313
314            // API path
315            op_context.insert("path".to_string(), path.clone());
316
317            // Name (operation ID or generated name)
318            op_context.insert("name".to_string(), name.clone());
319
320            // Store in context map
321            context_map.insert(name.clone(), op_context.clone());
322
323            // Also map by simplified name (for cases where filename doesn't match operation ID)
324            let simple_name = name.to_lowercase().replace('-', "_").replace(' ', "_");
325            if simple_name != name {
326                context_map.insert(simple_name, op_context);
327            }
328        }
329    }
330
331    // Also add schema names from components
332    if let Some(schemas) = spec.schemas() {
333        for (schema_name, _) in schemas {
334            let mut schema_context = HashMap::new();
335            schema_context.insert("name".to_string(), schema_name.clone());
336            schema_context.insert("tag".to_string(), "schemas".to_string());
337            schema_context.insert("operation".to_string(), String::new());
338            schema_context.insert("path".to_string(), String::new());
339
340            context_map.insert(schema_name.clone(), schema_context);
341        }
342    }
343
344    context.context_map = context_map;
345    context
346}
347
348/// Process generated files with output control options
349pub fn process_generated_file(
350    mut file: GeneratedFile,
351    config: &OutputConfig,
352    source_path: Option<&Path>,
353    naming_context: Option<&FileNamingContext>,
354) -> GeneratedFile {
355    // Apply file naming template if specified (before extension processing)
356    if let Some(template) = &config.file_naming_template {
357        // Extract base name and extension
358        let parent = file.path.parent().unwrap_or(Path::new("."));
359        let old_stem = file.path.file_stem().unwrap_or_default().to_string_lossy().to_string();
360
361        // Build context for template from OpenAPI spec if available
362        let context_values: HashMap<&str, &str> = if let Some(ctx) = naming_context {
363            // Try to get context for this file name
364            let mut values = ctx.get_context_for_name(&old_stem);
365            // If name not found, add name as default
366            if !values.contains_key("name") {
367                values.insert("name", &old_stem);
368            }
369            values
370        } else {
371            // Fallback to defaults
372            let mut fallback = HashMap::new();
373            fallback.insert("name", old_stem.as_str());
374            fallback.insert("tag", "api");
375            fallback.insert("operation", "");
376            fallback.insert("path", "");
377            fallback
378        };
379
380        let new_name = apply_file_naming_template(template, &context_values);
381
382        // Reconstruct path with new name
383        let ext = file.path.extension().and_then(|e| e.to_str()).unwrap_or("");
384        let new_filename = if ext.is_empty() {
385            new_name
386        } else {
387            format!("{}.{}", new_name, ext)
388        };
389        file.path = parent.join(new_filename);
390    }
391
392    // Apply extension override if specified
393    if let Some(ext) = &config.extension {
394        file.path = apply_extension(&file.path, Some(ext));
395        file.extension = ext.clone();
396    }
397
398    // Apply banner if specified
399    if let Some(banner_template) = &config.banner {
400        file.content = apply_banner(&file.content, banner_template, source_path);
401    }
402
403    file
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_apply_banner() {
412        let content = "export const test = 1;";
413        let banner = "Generated by {{generator}}\nSource: {{source}}";
414        let source = Some(Path::new("api.yaml"));
415
416        let result = apply_banner(content, banner, source);
417        assert!(result.contains("MockForge"));
418        assert!(result.contains("api.yaml"));
419        assert!(result.contains("export const test"));
420    }
421
422    #[test]
423    fn test_apply_extension() {
424        let path = Path::new("output/file.js");
425        let new_path = apply_extension(path, Some("ts"));
426        assert_eq!(new_path, PathBuf::from("output/file.ts"));
427    }
428
429    #[test]
430    fn test_apply_file_naming_template() {
431        let template = "{{name}}_{{tag}}";
432        let mut context = HashMap::new();
433        context.insert("name", "user");
434        context.insert("tag", "api");
435
436        let result = apply_file_naming_template(template, &context);
437        assert_eq!(result, "user_api");
438    }
439
440    #[test]
441    fn test_generate_index_file() {
442        let output_dir = Path::new("/tmp/test");
443        let files = vec![
444            GeneratedFile {
445                path: PathBuf::from("types.ts"),
446                content: "export type User = {};".to_string(),
447                extension: "ts".to_string(),
448                exportable: true,
449            },
450            GeneratedFile {
451                path: PathBuf::from("client.ts"),
452                content: "export const client = {};".to_string(),
453                extension: "ts".to_string(),
454                exportable: true,
455            },
456        ];
457
458        let result = BarrelGenerator::generate_index_file(output_dir, &files).unwrap();
459        assert_eq!(result.len(), 1);
460        assert!(result[0].0.ends_with("index.ts"));
461        assert!(result[0].1.contains("export * from './types'"));
462        assert!(result[0].1.contains("export * from './client'"));
463    }
464}