use ferro_type::{TypeDef, TypeRegistry, TS};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub const PRETTIFY_TYPE: &str = "type Prettify<T> = { [K in keyof T]: T[K] } & {};";
pub const PRETTIFY_TYPE_EXPORTED: &str = "export type Prettify<T> = { [K in keyof T]: T[K] } & {};";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExportStyle {
None,
#[default]
Named,
Grouped,
}
#[derive(Debug, Clone, Default)]
pub struct Config {
pub output: Option<PathBuf>,
pub export_style: ExportStyle,
pub declaration_only: bool,
pub header: Option<String>,
pub esm_extensions: bool,
pub include_utilities: bool,
}
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn output(mut self, path: impl AsRef<Path>) -> Self {
self.output = Some(path.as_ref().to_owned());
self
}
pub fn export_style(mut self, style: ExportStyle) -> Self {
self.export_style = style;
self
}
pub fn declaration_only(mut self) -> Self {
self.declaration_only = true;
self
}
pub fn header(mut self, header: impl Into<String>) -> Self {
self.header = Some(header.into());
self
}
pub fn esm_extensions(mut self) -> Self {
self.esm_extensions = true;
self
}
pub fn include_utilities(mut self) -> Self {
self.include_utilities = true;
self
}
}
#[derive(Debug)]
pub struct Generator {
config: Config,
registry: TypeRegistry,
}
impl Generator {
pub fn new(config: Config) -> Self {
Self {
config,
registry: TypeRegistry::new(),
}
}
pub fn with_defaults() -> Self {
Self::new(Config::default())
}
pub fn register<T: TS>(&mut self) -> &mut Self {
self.registry.register::<T>();
self
}
pub fn add(&mut self, typedef: TypeDef) -> &mut Self {
self.registry.add_typedef(typedef);
self
}
pub fn registry(&self) -> &TypeRegistry {
&self.registry
}
pub fn registry_mut(&mut self) -> &mut TypeRegistry {
&mut self.registry
}
pub fn generate(&self) -> String {
let mut output = String::new();
if let Some(ref header) = self.config.header {
output.push_str("// ");
output.push_str(header);
output.push('\n');
} else {
output.push_str("// Generated by ferro-type-gen\n");
output.push_str("// Do not edit manually\n");
}
output.push('\n');
if self.config.include_utilities {
match self.config.export_style {
ExportStyle::None => {
output.push_str(PRETTIFY_TYPE);
}
ExportStyle::Named | ExportStyle::Grouped => {
output.push_str(PRETTIFY_TYPE_EXPORTED);
}
}
output.push_str("\n\n");
}
match self.config.export_style {
ExportStyle::None => {
output.push_str(&self.registry.render());
}
ExportStyle::Named => {
output.push_str(&self.registry.render_exported());
}
ExportStyle::Grouped => {
output.push_str(&self.registry.render());
let names: Vec<_> = self.registry.sorted_types().into_iter().collect();
if !names.is_empty() {
output.push_str("\nexport { ");
output.push_str(&names.join(", "));
output.push_str(" };\n");
}
}
}
output
}
pub fn write(&self) -> std::io::Result<()> {
let output_path = self
.config
.output
.as_ref()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "No output path configured"))?;
if let Some(parent) = output_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let content = self.generate();
std::fs::write(output_path, content)
}
pub fn write_if_changed(&self) -> std::io::Result<bool> {
let output_path = self
.config
.output
.as_ref()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "No output path configured"))?;
let new_content = self.generate();
if output_path.exists() {
let existing = std::fs::read_to_string(output_path)?;
if existing == new_content {
return Ok(false); }
}
if let Some(parent) = output_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(output_path, new_content)?;
Ok(true) }
pub fn types_by_module(&self) -> HashMap<String, Vec<String>> {
let mut result: HashMap<String, Vec<String>> = HashMap::new();
for name in self.registry.type_names() {
if let Some(typedef) = self.registry.get(name) {
let module = match typedef {
TypeDef::Named { module, .. } => {
module.clone().unwrap_or_else(|| "default".to_string())
}
_ => "default".to_string(),
};
result.entry(module).or_default().push(name.to_string());
}
}
result
}
pub fn module_to_path(module: &str) -> PathBuf {
let parts: Vec<&str> = module.split("::").collect();
let path_parts = if parts.len() > 1 {
&parts[1..]
} else {
&parts[..]
};
let mut path = PathBuf::new();
for part in path_parts {
path.push(part);
}
path.set_extension("ts");
path
}
pub fn generate_for_module(&self, module: &str, type_names: &[String]) -> String {
let mut output = String::new();
if let Some(ref header) = self.config.header {
output.push_str("// ");
output.push_str(header);
output.push('\n');
} else {
output.push_str("// Generated by ferro-type-gen\n");
output.push_str("// Do not edit manually\n");
output.push_str("// Module: ");
output.push_str(module);
output.push('\n');
}
output.push('\n');
let sorted = self.registry.sorted_types();
let module_types: Vec<_> = sorted
.into_iter()
.filter(|name| type_names.contains(&name.to_string()))
.collect();
for name in module_types {
if let Some(typedef) = self.registry.get(name) {
if let TypeDef::Named { name, def, .. } = typedef {
let export_prefix = match self.config.export_style {
ExportStyle::None => "",
ExportStyle::Named | ExportStyle::Grouped => "export ",
};
output.push_str(&format!("{}type {} = {};\n\n", export_prefix, name, def.render()));
}
}
}
output
}
pub fn write_multi_file(&self, output_dir: impl AsRef<Path>) -> std::io::Result<usize> {
let output_dir = output_dir.as_ref();
let types_by_module = self.types_by_module();
let mut count = 0;
for (module, type_names) in &types_by_module {
let file_path = if module == "default" {
output_dir.join("types.ts")
} else {
output_dir.join(Self::module_to_path(module))
};
if let Some(parent) = file_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let content = self.generate_for_module(module, type_names);
std::fs::write(&file_path, content)?;
count += 1;
}
Ok(count)
}
pub fn write_multi_file_if_changed(&self, output_dir: impl AsRef<Path>) -> std::io::Result<usize> {
let output_dir = output_dir.as_ref();
let types_by_module = self.types_by_module();
let mut count = 0;
for (module, type_names) in &types_by_module {
let file_path = if module == "default" {
output_dir.join("types.ts")
} else {
output_dir.join(Self::module_to_path(module))
};
let new_content = self.generate_for_module(module, type_names);
let should_write = if file_path.exists() {
let existing = std::fs::read_to_string(&file_path)?;
existing != new_content
} else {
true
};
if should_write {
if let Some(parent) = file_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(&file_path, new_content)?;
count += 1;
}
}
Ok(count)
}
}
impl Default for Generator {
fn default() -> Self {
Self::with_defaults()
}
}
pub fn generate<T: TS>() -> String {
let mut generator = Generator::with_defaults();
generator.register::<T>();
generator.generate()
}
pub fn export_to_file<P: AsRef<Path>>(path: P, registry: &TypeRegistry) -> std::io::Result<()> {
let content = registry.render_exported();
let path = path.as_ref();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, content)
}
#[cfg(test)]
mod tests {
use super::*;
use ferro_type::{Field, Primitive, TypeDef};
#[test]
fn test_config_builder() {
let config = Config::new()
.output("types.ts")
.export_style(ExportStyle::Named)
.header("Custom header")
.declaration_only()
.esm_extensions();
assert_eq!(config.output, Some(PathBuf::from("types.ts")));
assert_eq!(config.export_style, ExportStyle::Named);
assert_eq!(config.header, Some("Custom header".to_string()));
assert!(config.declaration_only);
assert!(config.esm_extensions);
}
#[test]
fn test_generator_register() {
let mut generator = Generator::with_defaults();
generator.register::<String>();
assert_eq!(generator.registry().len(), 0);
}
#[test]
fn test_generator_add_typedef() {
let mut generator = Generator::with_defaults();
let user_type = TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Object(vec![
Field::new("id", TypeDef::Primitive(Primitive::String)),
Field::new("name", TypeDef::Primitive(Primitive::String)),
])),
module: None,
wrapper: None,
};
generator.add(user_type);
assert_eq!(generator.registry().len(), 1);
assert!(generator.registry().get("User").is_some());
}
#[test]
fn test_generate_export_none() {
let mut generator = Generator::new(Config::new().export_style(ExportStyle::None));
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
let output = generator.generate();
assert!(output.contains("type User = string;"));
assert!(!output.contains("export type User"));
}
#[test]
fn test_generate_export_named() {
let mut generator = Generator::new(Config::new().export_style(ExportStyle::Named));
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
let output = generator.generate();
assert!(output.contains("export type User = string;"));
}
#[test]
fn test_generate_export_grouped() {
let mut generator = Generator::new(Config::new().export_style(ExportStyle::Grouped));
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
generator.add(TypeDef::Named {
namespace: vec![],
name: "Post".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
let output = generator.generate();
assert!(output.contains("type User = string;"));
assert!(output.contains("type Post = string;"));
assert!(output.contains("export { "));
assert!(output.contains("User"));
assert!(output.contains("Post"));
}
#[test]
fn test_generate_custom_header() {
let generator = Generator::new(Config::new().header("My custom header"));
let output = generator.generate();
assert!(output.starts_with("// My custom header\n"));
}
#[test]
fn test_generate_default_header() {
let generator = Generator::with_defaults();
let output = generator.generate();
assert!(output.contains("// Generated by ferro-type-gen"));
assert!(output.contains("// Do not edit manually"));
}
#[test]
fn test_include_utilities() {
let generator = Generator::new(Config::new().include_utilities());
let output = generator.generate();
assert!(output.contains("export type Prettify<T>"));
assert!(output.contains("{ [K in keyof T]: T[K] }"));
}
#[test]
fn test_include_utilities_no_export() {
let generator = Generator::new(
Config::new()
.export_style(ExportStyle::None)
.include_utilities()
);
let output = generator.generate();
assert!(output.contains("type Prettify<T>"));
assert!(!output.contains("export type Prettify"));
}
#[test]
fn test_no_utilities_by_default() {
let generator = Generator::with_defaults();
let output = generator.generate();
assert!(!output.contains("Prettify"));
}
#[test]
fn test_write_creates_parent_dirs() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("nested/dir/types.ts");
let mut generator = Generator::new(Config::new().output(&output_path));
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
generator.write().unwrap();
assert!(output_path.exists());
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(content.contains("export type User = string;"));
}
#[test]
fn test_write_if_changed() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("types.ts");
let mut generator = Generator::new(Config::new().output(&output_path));
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
assert!(generator.write_if_changed().unwrap());
assert!(!generator.write_if_changed().unwrap());
generator.add(TypeDef::Named {
namespace: vec![],
name: "Post".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
assert!(generator.write_if_changed().unwrap());
}
#[test]
fn test_write_no_output_configured() {
let generator = Generator::with_defaults();
let result = generator.write();
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn test_convenience_generate() {
let output = generate::<String>();
assert!(output.contains("// Generated by ferro-type-gen"));
}
#[test]
fn test_convenience_export_to_file() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("types.ts");
let mut registry = TypeRegistry::new();
registry.add_typedef(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
export_to_file(&output_path, ®istry).unwrap();
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(content.contains("export type User = string;"));
}
#[test]
fn test_module_to_path() {
assert_eq!(
Generator::module_to_path("my_crate::models::user"),
PathBuf::from("models/user.ts")
);
assert_eq!(
Generator::module_to_path("my_crate::api"),
PathBuf::from("api.ts")
);
assert_eq!(
Generator::module_to_path("my_crate::nested::deep::module"),
PathBuf::from("nested/deep/module.ts")
);
}
#[test]
fn test_types_by_module() {
let mut generator = Generator::with_defaults();
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: Some("my_crate::models".to_string()),
wrapper: None,
});
generator.add(TypeDef::Named {
namespace: vec![],
name: "Post".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: Some("my_crate::models".to_string()),
wrapper: None,
});
generator.add(TypeDef::Named {
namespace: vec![],
name: "Request".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: Some("my_crate::api".to_string()),
wrapper: None,
});
generator.add(TypeDef::Named {
namespace: vec![],
name: "Orphan".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
let by_module = generator.types_by_module();
assert_eq!(by_module.len(), 3);
assert!(by_module.get("my_crate::models").unwrap().contains(&"User".to_string()));
assert!(by_module.get("my_crate::models").unwrap().contains(&"Post".to_string()));
assert!(by_module.get("my_crate::api").unwrap().contains(&"Request".to_string()));
assert!(by_module.get("default").unwrap().contains(&"Orphan".to_string()));
}
#[test]
fn test_generate_for_module() {
let mut generator = Generator::with_defaults();
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: Some("my_crate::models".to_string()),
wrapper: None,
});
generator.add(TypeDef::Named {
namespace: vec![],
name: "Post".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::Number)),
module: Some("my_crate::models".to_string()),
wrapper: None,
});
let output = generator.generate_for_module("my_crate::models", &["User".to_string(), "Post".to_string()]);
assert!(output.contains("// Module: my_crate::models"));
assert!(output.contains("export type User = string;"));
assert!(output.contains("export type Post = number;"));
}
#[test]
fn test_write_multi_file() {
let temp_dir = tempfile::tempdir().unwrap();
let mut generator = Generator::with_defaults();
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: Some("my_crate::models::user".to_string()),
wrapper: None,
});
generator.add(TypeDef::Named {
namespace: vec![],
name: "Request".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: Some("my_crate::api".to_string()),
wrapper: None,
});
let count = generator.write_multi_file(temp_dir.path()).unwrap();
assert_eq!(count, 2);
let user_path = temp_dir.path().join("models/user.ts");
let api_path = temp_dir.path().join("api.ts");
assert!(user_path.exists());
assert!(api_path.exists());
let user_content = std::fs::read_to_string(&user_path).unwrap();
assert!(user_content.contains("export type User = string;"));
let api_content = std::fs::read_to_string(&api_path).unwrap();
assert!(api_content.contains("export type Request = string;"));
}
#[test]
fn test_write_multi_file_if_changed() {
let temp_dir = tempfile::tempdir().unwrap();
let mut generator = Generator::with_defaults();
generator.add(TypeDef::Named {
namespace: vec![],
name: "User".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: Some("my_crate::models".to_string()),
wrapper: None,
});
let count1 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
assert_eq!(count1, 1);
let count2 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
assert_eq!(count2, 0);
generator.add(TypeDef::Named {
namespace: vec![],
name: "Post".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::Number)),
module: Some("my_crate::models".to_string()),
wrapper: None,
});
let count3 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
assert_eq!(count3, 1);
}
#[test]
fn test_write_multi_file_default_module() {
let temp_dir = tempfile::tempdir().unwrap();
let mut generator = Generator::with_defaults();
generator.add(TypeDef::Named {
namespace: vec![],
name: "Orphan".to_string(),
def: Box::new(TypeDef::Primitive(Primitive::String)),
module: None,
wrapper: None,
});
generator.write_multi_file(temp_dir.path()).unwrap();
let types_path = temp_dir.path().join("types.ts");
assert!(types_path.exists());
let content = std::fs::read_to_string(&types_path).unwrap();
assert!(content.contains("export type Orphan = string;"));
}
}