mockforge-core 0.3.114

Shared logic for MockForge - routing, validation, latency, proxy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! Output control utilities for MockForge generation
//!
//! This module provides functionality for:
//! - Generating barrel/index files
//! - Applying file extensions and banners
//! - Customizing file naming patterns

use crate::generate_config::{BarrelType, OutputConfig};
use crate::openapi::spec::OpenApiSpec;
use chrono::Utc;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// Represents a generated file with metadata
#[derive(Debug, Clone)]
pub struct GeneratedFile {
    /// Relative path from output directory
    pub path: PathBuf,
    /// File content
    pub content: String,
    /// File extension (without dot)
    pub extension: String,
    /// Whether this file should be included in barrel exports
    pub exportable: bool,
}

/// Generate barrel/index files for a directory structure
pub struct BarrelGenerator;

impl BarrelGenerator {
    /// Generate barrel files based on the configuration
    ///
    /// # Arguments
    /// * `output_dir` - Root output directory
    /// * `files` - List of generated files
    /// * `barrel_type` - Type of barrel files to generate
    ///
    /// # Returns
    /// Vector of barrel file paths and contents
    pub fn generate_barrel_files(
        output_dir: &Path,
        files: &[GeneratedFile],
        barrel_type: BarrelType,
    ) -> Result<Vec<(PathBuf, String)>, crate::Error> {
        match barrel_type {
            BarrelType::None => Ok(vec![]),
            BarrelType::Index => Self::generate_index_file(output_dir, files),
            BarrelType::Barrel => Self::generate_barrel_structure(output_dir, files),
        }
    }

    /// Generate a single index.ts file at the root
    fn generate_index_file(
        output_dir: &Path,
        files: &[GeneratedFile],
    ) -> Result<Vec<(PathBuf, String)>, crate::Error> {
        let mut exports = Vec::new();

        // Collect exportable files (TypeScript/JavaScript files)
        for file in files {
            if !file.exportable {
                continue;
            }

            // Determine relative import path
            let rel_path = file.path.clone();
            let import_path = if rel_path.extension().is_some() {
                // Remove extension for import
                rel_path.with_extension("")
            } else {
                rel_path
            };

            // Convert to forward slashes for import paths
            let import_str = import_path
                .to_string_lossy()
                .replace('\\', "/")
                .trim_start_matches("./")
                .to_string();

            // Generate export statement based on extension
            let export = match file.extension.as_str() {
                "ts" | "tsx" => {
                    format!("export * from './{}';", import_str)
                }
                "js" | "jsx" | "mjs" => {
                    // For JS files, may need default exports or named exports
                    format!("export * from './{}';", import_str)
                }
                _ => continue, // Skip non-exportable files
            };

            exports.push(export);
        }

        // Sort exports for consistent output
        exports.sort();

        // Generate index.ts content
        let index_content = if exports.is_empty() {
            "// Generated by MockForge\n// No exportable files found\n".to_string()
        } else {
            format!(
                "// Generated by MockForge\n// Barrel file - exports all generated modules\n\n{}\n",
                exports.join("\n")
            )
        };

        let index_path = output_dir.join("index.ts");
        Ok(vec![(index_path, index_content)])
    }

    /// Generate barrel structure (index files at multiple directory levels)
    fn generate_barrel_structure(
        output_dir: &Path,
        files: &[GeneratedFile],
    ) -> Result<Vec<(PathBuf, String)>, crate::Error> {
        // Group files by directory
        let mut dir_exports: HashMap<PathBuf, Vec<(String, PathBuf)>> = HashMap::new();

        for file in files {
            if !file.exportable {
                continue;
            }

            let parent = file.path.parent().unwrap_or(Path::new("."));

            // Create import path relative to parent directory (not absolute from root)
            // If file is "api/types.ts" and parent is "api", import should be "./types"
            let file_stem = file.path.file_stem().unwrap_or_default();
            let import_str = file_stem.to_string_lossy().to_string();

            dir_exports
                .entry(parent.to_path_buf())
                .or_default()
                .push((format!("export * from './{}';", import_str), file.path.clone()));
        }

        let mut barrel_files = Vec::new();

        // Generate index.ts for each directory with exports
        for (dir, exports) in dir_exports {
            let mut export_lines: Vec<String> = exports.iter().map(|(e, _)| e.clone()).collect();
            export_lines.sort();

            let index_content = if export_lines.is_empty() {
                continue;
            } else {
                format!(
                    "// Generated by MockForge\n// Barrel file for directory: {}\n\n{}\n",
                    dir.display(),
                    export_lines.join("\n")
                )
            };

            let index_path = if dir == Path::new(".") {
                output_dir.join("index.ts")
            } else {
                output_dir.join(dir).join("index.ts")
            };

            barrel_files.push((index_path, index_content));
        }

        Ok(barrel_files)
    }
}

/// Apply banner to file content
pub fn apply_banner(content: &str, banner_template: &str, source_path: Option<&Path>) -> String {
    // Replace template placeholders with actual values
    let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
    let generator = "MockForge";
    let source = source_path
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| "unknown".to_string());

    let banner = banner_template
        .replace("{{timestamp}}", &timestamp)
        .replace("{{source}}", &source)
        .replace("{{generator}}", generator);

    // Detect file type from content to determine comment style
    let comment_style = if content.trim_start().starts_with("//")
        || content.trim_start().starts_with("/*")
        || content.trim_start().starts_with("*")
    {
        // Already has comments - assume line comments
        "line"
    } else if content.trim_start().starts_with("#") {
        // Script file (shell, Python, etc.)
        "hash"
    } else {
        // Default: try to infer from common patterns
        // Check if it looks like TypeScript/JavaScript
        if content.contains("export") || content.contains("import") {
            "line"
        } else {
            "block"
        }
    };

    // Format banner according to detected style
    let formatted_banner = match comment_style {
        "hash" => {
            // Hash-style comments (#)
            format!("# {}\n", banner.replace('\n', "\n# "))
        }
        "line" => {
            // Line-style comments (//)
            format!("// {}\n", banner.replace('\n', "\n// "))
        }
        _ => {
            // Block-style comments (/* */)
            format!("/*\n * {}\n */\n", banner.replace('\n', "\n * "))
        }
    };

    format!("{}\n{}", formatted_banner, content)
}

/// Apply file extension override
pub fn apply_extension(file_path: &Path, extension: Option<&str>) -> PathBuf {
    match extension {
        Some(ext) => {
            // Remove any existing extension and add new one
            file_path.with_extension(ext)
        }
        None => file_path.to_path_buf(),
    }
}

/// Apply file naming template
pub fn apply_file_naming_template(template: &str, context: &HashMap<&str, &str>) -> String {
    let mut result = template.to_string();

    // Replace all placeholders
    for (key, value) in context {
        let placeholder = format!("{{{{{}}}}}", key);
        result = result.replace(&placeholder, value);
    }

    result
}

/// Context for file naming templates extracted from OpenAPI spec
///
/// This struct holds contextual information extracted from OpenAPI specifications
/// that can be used to generate file names using template patterns.
#[derive(Debug, Clone)]
pub struct FileNamingContext {
    /// Mapping of file/operation names to their context values
    /// Contains operation-specific values like tag, path, method, etc.
    context_map: HashMap<String, HashMap<String, String>>,
    /// Default context values when no specific mapping exists
    /// Used as fallback when a name is not found in the context map
    defaults: HashMap<String, String>,
}

impl FileNamingContext {
    /// Create a new empty context with defaults
    pub fn new() -> Self {
        let mut defaults = HashMap::new();
        defaults.insert("tag".to_string(), "api".to_string());
        defaults.insert("operation".to_string(), String::new());
        defaults.insert("path".to_string(), String::new());

        Self {
            context_map: HashMap::new(),
            defaults,
        }
    }

    /// Get context values for a given name
    pub fn get_context_for_name(&self, name: &str) -> HashMap<&str, &str> {
        if let Some(context) = self.context_map.get(name) {
            context.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
        } else {
            // Return defaults
            self.defaults.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
        }
    }
}

impl Default for FileNamingContext {
    fn default() -> Self {
        Self::new()
    }
}

/// Build file naming context from OpenAPI specification
pub fn build_file_naming_context(spec: &OpenApiSpec) -> FileNamingContext {
    let mut context = FileNamingContext::new();
    let mut context_map = HashMap::new();

    // Extract all paths and operations
    let all_paths = spec.all_paths_and_operations();

    for (path, operations) in all_paths {
        for (method, operation) in operations {
            // Build name from operation ID, or generate one from method + path
            let name = operation.operation_id.clone().unwrap_or_else(|| {
                // Generate name from method and path
                let path_name = path.trim_matches('/').replace('/', "_").replace(['{', '}'], "");
                format!("{}_{}", method.to_lowercase(), path_name)
            });

            // Build context for this operation
            let mut op_context = HashMap::new();

            // Get primary tag (first tag, or empty)
            let tag = operation.tags.first().cloned().unwrap_or_else(|| "api".to_string());
            op_context.insert("tag".to_string(), tag);

            // Operation method
            op_context.insert("operation".to_string(), method.to_lowercase());

            // API path
            op_context.insert("path".to_string(), path.clone());

            // Name (operation ID or generated name)
            op_context.insert("name".to_string(), name.clone());

            // Store in context map
            context_map.insert(name.clone(), op_context.clone());

            // Also map by simplified name (for cases where filename doesn't match operation ID)
            let simple_name = name.to_lowercase().replace(['-', ' '], "_");
            if simple_name != name {
                context_map.insert(simple_name, op_context);
            }
        }
    }

    // Also add schema names from components
    if let Some(schemas) = spec.schemas() {
        for (schema_name, _) in schemas {
            let mut schema_context = HashMap::new();
            schema_context.insert("name".to_string(), schema_name.clone());
            schema_context.insert("tag".to_string(), "schemas".to_string());
            schema_context.insert("operation".to_string(), String::new());
            schema_context.insert("path".to_string(), String::new());

            context_map.insert(schema_name.clone(), schema_context);
        }
    }

    context.context_map = context_map;
    context
}

/// Process generated files with output control options
pub fn process_generated_file(
    mut file: GeneratedFile,
    config: &OutputConfig,
    source_path: Option<&Path>,
    naming_context: Option<&FileNamingContext>,
) -> GeneratedFile {
    // Apply file naming template if specified (before extension processing)
    if let Some(template) = &config.file_naming_template {
        // Extract base name and extension
        let parent = file.path.parent().unwrap_or(Path::new("."));
        let old_stem = file.path.file_stem().unwrap_or_default().to_string_lossy().to_string();

        // Build context for template from OpenAPI spec if available
        let context_values: HashMap<&str, &str> = if let Some(ctx) = naming_context {
            // Try to get context for this file name
            let mut values = ctx.get_context_for_name(&old_stem);
            // If name not found, add name as default
            if !values.contains_key("name") {
                values.insert("name", &old_stem);
            }
            values
        } else {
            // Fallback to defaults
            let mut fallback = HashMap::new();
            fallback.insert("name", old_stem.as_str());
            fallback.insert("tag", "api");
            fallback.insert("operation", "");
            fallback.insert("path", "");
            fallback
        };

        let new_name = apply_file_naming_template(template, &context_values);

        // Reconstruct path with new name
        let ext = file.path.extension().and_then(|e| e.to_str()).unwrap_or("");
        let new_filename = if ext.is_empty() {
            new_name
        } else {
            format!("{}.{}", new_name, ext)
        };
        file.path = parent.join(new_filename);
    }

    // Apply extension override if specified
    if let Some(ext) = &config.extension {
        file.path = apply_extension(&file.path, Some(ext));
        file.extension = ext.clone();
    }

    // Apply banner if specified
    if let Some(banner_template) = &config.banner {
        file.content = apply_banner(&file.content, banner_template, source_path);
    }

    file
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_apply_banner() {
        let content = "export const test = 1;";
        let banner = "Generated by {{generator}}\nSource: {{source}}";
        let source = Some(Path::new("api.yaml"));

        let result = apply_banner(content, banner, source);
        assert!(result.contains("MockForge"));
        assert!(result.contains("api.yaml"));
        assert!(result.contains("export const test"));
    }

    #[test]
    fn test_apply_extension() {
        let path = Path::new("output/file.js");
        let new_path = apply_extension(path, Some("ts"));
        assert_eq!(new_path, PathBuf::from("output/file.ts"));
    }

    #[test]
    fn test_apply_file_naming_template() {
        let template = "{{name}}_{{tag}}";
        let mut context = HashMap::new();
        context.insert("name", "user");
        context.insert("tag", "api");

        let result = apply_file_naming_template(template, &context);
        assert_eq!(result, "user_api");
    }

    #[test]
    fn test_generate_index_file() {
        let output_dir = Path::new("/tmp/test");
        let files = vec![
            GeneratedFile {
                path: PathBuf::from("types.ts"),
                content: "export type User = {};".to_string(),
                extension: "ts".to_string(),
                exportable: true,
            },
            GeneratedFile {
                path: PathBuf::from("client.ts"),
                content: "export const client = {};".to_string(),
                extension: "ts".to_string(),
                exportable: true,
            },
        ];

        let result = BarrelGenerator::generate_index_file(output_dir, &files).unwrap();
        assert_eq!(result.len(), 1);
        assert!(result[0].0.ends_with("index.ts"));
        assert!(result[0].1.contains("export * from './types'"));
        assert!(result[0].1.contains("export * from './client'"));
    }
}