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///
248/// This struct holds contextual information extracted from OpenAPI specifications
249/// that can be used to generate file names using template patterns.
250#[derive(Debug, Clone)]
251pub struct FileNamingContext {
252    /// Mapping of file/operation names to their context values
253    /// Contains operation-specific values like tag, path, method, etc.
254    context_map: HashMap<String, HashMap<String, String>>,
255    /// Default context values when no specific mapping exists
256    /// Used as fallback when a name is not found in the context map
257    defaults: HashMap<String, String>,
258}
259
260impl FileNamingContext {
261    /// Create a new empty context with defaults
262    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    /// Get context values for a given name
275    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            // Return defaults
280            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
291/// Build file naming context from OpenAPI specification
292pub fn build_file_naming_context(spec: &OpenApiSpec) -> FileNamingContext {
293    let mut context = FileNamingContext::new();
294    let mut context_map = HashMap::new();
295
296    // Extract all paths and operations
297    let all_paths = spec.all_paths_and_operations();
298
299    for (path, operations) in all_paths {
300        for (method, operation) in operations {
301            // Build name from operation ID, or generate one from method + path
302            let name = operation.operation_id.clone().unwrap_or_else(|| {
303                // Generate name from method and path
304                let path_name =
305                    path.trim_matches('/').replace('/', "_").replace('{', "").replace('}', "");
306                format!("{}_{}", method.to_lowercase(), path_name)
307            });
308
309            // Build context for this operation
310            let mut op_context = HashMap::new();
311
312            // Get primary tag (first tag, or empty)
313            let tag = operation.tags.first().cloned().unwrap_or_else(|| "api".to_string());
314            op_context.insert("tag".to_string(), tag);
315
316            // Operation method
317            op_context.insert("operation".to_string(), method.to_lowercase());
318
319            // API path
320            op_context.insert("path".to_string(), path.clone());
321
322            // Name (operation ID or generated name)
323            op_context.insert("name".to_string(), name.clone());
324
325            // Store in context map
326            context_map.insert(name.clone(), op_context.clone());
327
328            // Also map by simplified name (for cases where filename doesn't match operation ID)
329            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    // Also add schema names from components
337    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
353/// Process generated files with output control options
354pub fn process_generated_file(
355    mut file: GeneratedFile,
356    config: &OutputConfig,
357    source_path: Option<&Path>,
358    naming_context: Option<&FileNamingContext>,
359) -> GeneratedFile {
360    // Apply file naming template if specified (before extension processing)
361    if let Some(template) = &config.file_naming_template {
362        // Extract base name and extension
363        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        // Build context for template from OpenAPI spec if available
367        let context_values: HashMap<&str, &str> = if let Some(ctx) = naming_context {
368            // Try to get context for this file name
369            let mut values = ctx.get_context_for_name(&old_stem);
370            // If name not found, add name as default
371            if !values.contains_key("name") {
372                values.insert("name", &old_stem);
373            }
374            values
375        } else {
376            // Fallback to defaults
377            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        // Reconstruct path with new name
388        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    // Apply extension override if specified
398    if let Some(ext) = &config.extension {
399        file.path = apply_extension(&file.path, Some(ext));
400        file.extension = ext.clone();
401    }
402
403    // Apply banner if specified
404    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}