use crate::generate_config::{BarrelType, OutputConfig};
use crate::openapi::spec::OpenApiSpec;
use chrono::Utc;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct GeneratedFile {
pub path: PathBuf,
pub content: String,
pub extension: String,
pub exportable: bool,
}
pub struct BarrelGenerator;
impl BarrelGenerator {
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),
}
}
fn generate_index_file(
output_dir: &Path,
files: &[GeneratedFile],
) -> Result<Vec<(PathBuf, String)>, crate::Error> {
let mut exports = Vec::new();
for file in files {
if !file.exportable {
continue;
}
let rel_path = file.path.clone();
let import_path = if rel_path.extension().is_some() {
rel_path.with_extension("")
} else {
rel_path
};
let import_str = import_path
.to_string_lossy()
.replace('\\', "/")
.trim_start_matches("./")
.to_string();
let export = match file.extension.as_str() {
"ts" | "tsx" => {
format!("export * from './{}';", import_str)
}
"js" | "jsx" | "mjs" => {
format!("export * from './{}';", import_str)
}
_ => continue, };
exports.push(export);
}
exports.sort();
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)])
}
fn generate_barrel_structure(
output_dir: &Path,
files: &[GeneratedFile],
) -> Result<Vec<(PathBuf, String)>, crate::Error> {
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("."));
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();
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)
}
}
pub fn apply_banner(content: &str, banner_template: &str, source_path: Option<&Path>) -> String {
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}}", ×tamp)
.replace("{{source}}", &source)
.replace("{{generator}}", generator);
let comment_style = if content.trim_start().starts_with("//")
|| content.trim_start().starts_with("/*")
|| content.trim_start().starts_with("*")
{
"line"
} else if content.trim_start().starts_with("#") {
"hash"
} else {
if content.contains("export") || content.contains("import") {
"line"
} else {
"block"
}
};
let formatted_banner = match comment_style {
"hash" => {
format!("# {}\n", banner.replace('\n', "\n# "))
}
"line" => {
format!("// {}\n", banner.replace('\n', "\n// "))
}
_ => {
format!("/*\n * {}\n */\n", banner.replace('\n', "\n * "))
}
};
format!("{}\n{}", formatted_banner, content)
}
pub fn apply_extension(file_path: &Path, extension: Option<&str>) -> PathBuf {
match extension {
Some(ext) => {
file_path.with_extension(ext)
}
None => file_path.to_path_buf(),
}
}
pub fn apply_file_naming_template(template: &str, context: &HashMap<&str, &str>) -> String {
let mut result = template.to_string();
for (key, value) in context {
let placeholder = format!("{{{{{}}}}}", key);
result = result.replace(&placeholder, value);
}
result
}
#[derive(Debug, Clone)]
pub struct FileNamingContext {
context_map: HashMap<String, HashMap<String, String>>,
defaults: HashMap<String, String>,
}
impl FileNamingContext {
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,
}
}
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 {
self.defaults.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
}
}
}
impl Default for FileNamingContext {
fn default() -> Self {
Self::new()
}
}
pub fn build_file_naming_context(spec: &OpenApiSpec) -> FileNamingContext {
let mut context = FileNamingContext::new();
let mut context_map = HashMap::new();
let all_paths = spec.all_paths_and_operations();
for (path, operations) in all_paths {
for (method, operation) in operations {
let name = operation.operation_id.clone().unwrap_or_else(|| {
let path_name = path.trim_matches('/').replace('/', "_").replace(['{', '}'], "");
format!("{}_{}", method.to_lowercase(), path_name)
});
let mut op_context = HashMap::new();
let tag = operation.tags.first().cloned().unwrap_or_else(|| "api".to_string());
op_context.insert("tag".to_string(), tag);
op_context.insert("operation".to_string(), method.to_lowercase());
op_context.insert("path".to_string(), path.clone());
op_context.insert("name".to_string(), name.clone());
context_map.insert(name.clone(), op_context.clone());
let simple_name = name.to_lowercase().replace(['-', ' '], "_");
if simple_name != name {
context_map.insert(simple_name, op_context);
}
}
}
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
}
pub fn process_generated_file(
mut file: GeneratedFile,
config: &OutputConfig,
source_path: Option<&Path>,
naming_context: Option<&FileNamingContext>,
) -> GeneratedFile {
if let Some(template) = &config.file_naming_template {
let parent = file.path.parent().unwrap_or(Path::new("."));
let old_stem = file.path.file_stem().unwrap_or_default().to_string_lossy().to_string();
let context_values: HashMap<&str, &str> = if let Some(ctx) = naming_context {
let mut values = ctx.get_context_for_name(&old_stem);
if !values.contains_key("name") {
values.insert("name", &old_stem);
}
values
} else {
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);
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);
}
if let Some(ext) = &config.extension {
file.path = apply_extension(&file.path, Some(ext));
file.extension = ext.clone();
}
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'"));
}
}