1use ferro_type::{TypeDef, TypeRegistry, TypeScript};
48use std::collections::HashMap;
49use std::path::{Path, PathBuf};
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum ExportStyle {
54 None,
56 #[default]
58 Named,
59 Grouped,
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct Config {
66 pub output: Option<PathBuf>,
68
69 pub export_style: ExportStyle,
71
72 pub declaration_only: bool,
74
75 pub header: Option<String>,
77
78 pub esm_extensions: bool,
81}
82
83impl Config {
84 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn output(mut self, path: impl AsRef<Path>) -> Self {
91 self.output = Some(path.as_ref().to_owned());
92 self
93 }
94
95 pub fn export_style(mut self, style: ExportStyle) -> Self {
97 self.export_style = style;
98 self
99 }
100
101 pub fn declaration_only(mut self) -> Self {
103 self.declaration_only = true;
104 self
105 }
106
107 pub fn header(mut self, header: impl Into<String>) -> Self {
109 self.header = Some(header.into());
110 self
111 }
112
113 pub fn esm_extensions(mut self) -> Self {
115 self.esm_extensions = true;
116 self
117 }
118}
119
120#[derive(Debug)]
124pub struct Generator {
125 config: Config,
126 registry: TypeRegistry,
127}
128
129impl Generator {
130 pub fn new(config: Config) -> Self {
132 Self {
133 config,
134 registry: TypeRegistry::new(),
135 }
136 }
137
138 pub fn with_defaults() -> Self {
140 Self::new(Config::default())
141 }
142
143 pub fn register<T: TypeScript>(&mut self) -> &mut Self {
148 self.registry.register::<T>();
149 self
150 }
151
152 pub fn add(&mut self, typedef: TypeDef) -> &mut Self {
156 self.registry.add_typedef(typedef);
157 self
158 }
159
160 pub fn registry(&self) -> &TypeRegistry {
162 &self.registry
163 }
164
165 pub fn registry_mut(&mut self) -> &mut TypeRegistry {
167 &mut self.registry
168 }
169
170 pub fn generate(&self) -> String {
172 let mut output = String::new();
173
174 if let Some(ref header) = self.config.header {
176 output.push_str("// ");
177 output.push_str(header);
178 output.push('\n');
179 } else {
180 output.push_str("// Generated by ferro-type-gen\n");
181 output.push_str("// Do not edit manually\n");
182 }
183 output.push('\n');
184
185 match self.config.export_style {
187 ExportStyle::None => {
188 output.push_str(&self.registry.render());
189 }
190 ExportStyle::Named => {
191 output.push_str(&self.registry.render_exported());
192 }
193 ExportStyle::Grouped => {
194 output.push_str(&self.registry.render());
196 let names: Vec<_> = self.registry.sorted_types().into_iter().collect();
198 if !names.is_empty() {
199 output.push_str("\nexport { ");
200 output.push_str(&names.join(", "));
201 output.push_str(" };\n");
202 }
203 }
204 }
205
206 output
207 }
208
209 pub fn write(&self) -> std::io::Result<()> {
217 let output_path = self
218 .config
219 .output
220 .as_ref()
221 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "No output path configured"))?;
222
223 if let Some(parent) = output_path.parent() {
225 if !parent.as_os_str().is_empty() {
226 std::fs::create_dir_all(parent)?;
227 }
228 }
229
230 let content = self.generate();
231 std::fs::write(output_path, content)
232 }
233
234 pub fn write_if_changed(&self) -> std::io::Result<bool> {
245 let output_path = self
246 .config
247 .output
248 .as_ref()
249 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "No output path configured"))?;
250
251 let new_content = self.generate();
252
253 if output_path.exists() {
255 let existing = std::fs::read_to_string(output_path)?;
256 if existing == new_content {
257 return Ok(false); }
259 }
260
261 if let Some(parent) = output_path.parent() {
263 if !parent.as_os_str().is_empty() {
264 std::fs::create_dir_all(parent)?;
265 }
266 }
267 std::fs::write(output_path, new_content)?;
268 Ok(true) }
270
271 pub fn types_by_module(&self) -> HashMap<String, Vec<String>> {
280 let mut result: HashMap<String, Vec<String>> = HashMap::new();
281
282 for name in self.registry.type_names() {
283 if let Some(typedef) = self.registry.get(name) {
284 let module = match typedef {
285 TypeDef::Named { module, .. } => {
286 module.clone().unwrap_or_else(|| "default".to_string())
287 }
288 _ => "default".to_string(),
289 };
290 result.entry(module).or_default().push(name.to_string());
291 }
292 }
293
294 result
295 }
296
297 pub fn module_to_path(module: &str) -> PathBuf {
305 let parts: Vec<&str> = module.split("::").collect();
307 let path_parts = if parts.len() > 1 {
308 &parts[1..]
309 } else {
310 &parts[..]
311 };
312
313 let mut path = PathBuf::new();
314 for part in path_parts {
315 path.push(part);
316 }
317 path.set_extension("ts");
318 path
319 }
320
321 pub fn generate_for_module(&self, module: &str, type_names: &[String]) -> String {
325 let mut output = String::new();
326
327 if let Some(ref header) = self.config.header {
329 output.push_str("// ");
330 output.push_str(header);
331 output.push('\n');
332 } else {
333 output.push_str("// Generated by ferro-type-gen\n");
334 output.push_str("// Do not edit manually\n");
335 output.push_str("// Module: ");
336 output.push_str(module);
337 output.push('\n');
338 }
339 output.push('\n');
340
341 let sorted = self.registry.sorted_types();
343 let module_types: Vec<_> = sorted
344 .into_iter()
345 .filter(|name| type_names.contains(&name.to_string()))
346 .collect();
347
348 for name in module_types {
352 if let Some(typedef) = self.registry.get(name) {
353 if let TypeDef::Named { name, def, .. } = typedef {
354 let export_prefix = match self.config.export_style {
355 ExportStyle::None => "",
356 ExportStyle::Named | ExportStyle::Grouped => "export ",
357 };
358 output.push_str(&format!("{}type {} = {};\n\n", export_prefix, name, def.render()));
359 }
360 }
361 }
362
363 output
364 }
365
366 pub fn write_multi_file(&self, output_dir: impl AsRef<Path>) -> std::io::Result<usize> {
385 let output_dir = output_dir.as_ref();
386 let types_by_module = self.types_by_module();
387 let mut count = 0;
388
389 for (module, type_names) in &types_by_module {
390 let file_path = if module == "default" {
391 output_dir.join("types.ts")
392 } else {
393 output_dir.join(Self::module_to_path(module))
394 };
395
396 if let Some(parent) = file_path.parent() {
398 if !parent.as_os_str().is_empty() {
399 std::fs::create_dir_all(parent)?;
400 }
401 }
402
403 let content = self.generate_for_module(module, type_names);
404 std::fs::write(&file_path, content)?;
405 count += 1;
406 }
407
408 Ok(count)
409 }
410
411 pub fn write_multi_file_if_changed(&self, output_dir: impl AsRef<Path>) -> std::io::Result<usize> {
415 let output_dir = output_dir.as_ref();
416 let types_by_module = self.types_by_module();
417 let mut count = 0;
418
419 for (module, type_names) in &types_by_module {
420 let file_path = if module == "default" {
421 output_dir.join("types.ts")
422 } else {
423 output_dir.join(Self::module_to_path(module))
424 };
425
426 let new_content = self.generate_for_module(module, type_names);
427
428 let should_write = if file_path.exists() {
430 let existing = std::fs::read_to_string(&file_path)?;
431 existing != new_content
432 } else {
433 true
434 };
435
436 if should_write {
437 if let Some(parent) = file_path.parent() {
439 if !parent.as_os_str().is_empty() {
440 std::fs::create_dir_all(parent)?;
441 }
442 }
443 std::fs::write(&file_path, new_content)?;
444 count += 1;
445 }
446 }
447
448 Ok(count)
449 }
450}
451
452impl Default for Generator {
453 fn default() -> Self {
454 Self::with_defaults()
455 }
456}
457
458pub fn generate<T: TypeScript>() -> String {
466 let mut generator = Generator::with_defaults();
467 generator.register::<T>();
468 generator.generate()
469}
470
471pub fn export_to_file<P: AsRef<Path>>(path: P, registry: &TypeRegistry) -> std::io::Result<()> {
475 let content = registry.render_exported();
476
477 let path = path.as_ref();
479 if let Some(parent) = path.parent() {
480 if !parent.as_os_str().is_empty() {
481 std::fs::create_dir_all(parent)?;
482 }
483 }
484
485 std::fs::write(path, content)
486}
487
488#[cfg(test)]
493mod tests {
494 use super::*;
495 use ferro_type::{Field, Primitive, TypeDef};
496
497 #[test]
498 fn test_config_builder() {
499 let config = Config::new()
500 .output("types.ts")
501 .export_style(ExportStyle::Named)
502 .header("Custom header")
503 .declaration_only()
504 .esm_extensions();
505
506 assert_eq!(config.output, Some(PathBuf::from("types.ts")));
507 assert_eq!(config.export_style, ExportStyle::Named);
508 assert_eq!(config.header, Some("Custom header".to_string()));
509 assert!(config.declaration_only);
510 assert!(config.esm_extensions);
511 }
512
513 #[test]
514 fn test_generator_register() {
515 let mut generator = Generator::with_defaults();
516
517 generator.register::<String>();
519
520 assert_eq!(generator.registry().len(), 0);
522 }
523
524 #[test]
525 fn test_generator_add_typedef() {
526 let mut generator = Generator::with_defaults();
527
528 let user_type = TypeDef::Named {
529 namespace: vec![],
530 name: "User".to_string(),
531 def: Box::new(TypeDef::Object(vec![
532 Field::new("id", TypeDef::Primitive(Primitive::String)),
533 Field::new("name", TypeDef::Primitive(Primitive::String)),
534 ])),
535 module: None,
536 };
537
538 generator.add(user_type);
539
540 assert_eq!(generator.registry().len(), 1);
541 assert!(generator.registry().get("User").is_some());
542 }
543
544 #[test]
545 fn test_generate_export_none() {
546 let mut generator = Generator::new(Config::new().export_style(ExportStyle::None));
547
548 generator.add(TypeDef::Named {
549 namespace: vec![],
550 name: "User".to_string(),
551 def: Box::new(TypeDef::Primitive(Primitive::String)),
552 module: None,
553 });
554
555 let output = generator.generate();
556 assert!(output.contains("type User = string;"));
557 assert!(!output.contains("export type User"));
558 }
559
560 #[test]
561 fn test_generate_export_named() {
562 let mut generator = Generator::new(Config::new().export_style(ExportStyle::Named));
563
564 generator.add(TypeDef::Named {
565 namespace: vec![],
566 name: "User".to_string(),
567 def: Box::new(TypeDef::Primitive(Primitive::String)),
568 module: None,
569 });
570
571 let output = generator.generate();
572 assert!(output.contains("export type User = string;"));
573 }
574
575 #[test]
576 fn test_generate_export_grouped() {
577 let mut generator = Generator::new(Config::new().export_style(ExportStyle::Grouped));
578
579 generator.add(TypeDef::Named {
580 namespace: vec![],
581 name: "User".to_string(),
582 def: Box::new(TypeDef::Primitive(Primitive::String)),
583 module: None,
584 });
585 generator.add(TypeDef::Named {
586 namespace: vec![],
587 name: "Post".to_string(),
588 def: Box::new(TypeDef::Primitive(Primitive::String)),
589 module: None,
590 });
591
592 let output = generator.generate();
593 assert!(output.contains("type User = string;"));
594 assert!(output.contains("type Post = string;"));
595 assert!(output.contains("export { "));
596 assert!(output.contains("User"));
597 assert!(output.contains("Post"));
598 }
599
600 #[test]
601 fn test_generate_custom_header() {
602 let generator = Generator::new(Config::new().header("My custom header"));
603
604 let output = generator.generate();
605 assert!(output.starts_with("// My custom header\n"));
606 }
607
608 #[test]
609 fn test_generate_default_header() {
610 let generator = Generator::with_defaults();
611
612 let output = generator.generate();
613 assert!(output.contains("// Generated by ferro-type-gen"));
614 assert!(output.contains("// Do not edit manually"));
615 }
616
617 #[test]
618 fn test_write_creates_parent_dirs() {
619 let temp_dir = tempfile::tempdir().unwrap();
620 let output_path = temp_dir.path().join("nested/dir/types.ts");
621
622 let mut generator = Generator::new(Config::new().output(&output_path));
623 generator.add(TypeDef::Named {
624 namespace: vec![],
625 name: "User".to_string(),
626 def: Box::new(TypeDef::Primitive(Primitive::String)),
627 module: None,
628 });
629
630 generator.write().unwrap();
631
632 assert!(output_path.exists());
633 let content = std::fs::read_to_string(&output_path).unwrap();
634 assert!(content.contains("export type User = string;"));
635 }
636
637 #[test]
638 fn test_write_if_changed() {
639 let temp_dir = tempfile::tempdir().unwrap();
640 let output_path = temp_dir.path().join("types.ts");
641
642 let mut generator = Generator::new(Config::new().output(&output_path));
643 generator.add(TypeDef::Named {
644 namespace: vec![],
645 name: "User".to_string(),
646 def: Box::new(TypeDef::Primitive(Primitive::String)),
647 module: None,
648 });
649
650 assert!(generator.write_if_changed().unwrap());
652
653 assert!(!generator.write_if_changed().unwrap());
655
656 generator.add(TypeDef::Named {
658 namespace: vec![],
659 name: "Post".to_string(),
660 def: Box::new(TypeDef::Primitive(Primitive::String)),
661 module: None,
662 });
663
664 assert!(generator.write_if_changed().unwrap());
666 }
667
668 #[test]
669 fn test_write_no_output_configured() {
670 let generator = Generator::with_defaults();
671 let result = generator.write();
672 assert!(result.is_err());
673 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
674 }
675
676 #[test]
677 fn test_convenience_generate() {
678 let output = generate::<String>();
680 assert!(output.contains("// Generated by ferro-type-gen"));
682 }
683
684 #[test]
685 fn test_convenience_export_to_file() {
686 let temp_dir = tempfile::tempdir().unwrap();
687 let output_path = temp_dir.path().join("types.ts");
688
689 let mut registry = TypeRegistry::new();
690 registry.add_typedef(TypeDef::Named {
691 namespace: vec![],
692 name: "User".to_string(),
693 def: Box::new(TypeDef::Primitive(Primitive::String)),
694 module: None,
695 });
696
697 export_to_file(&output_path, ®istry).unwrap();
698
699 let content = std::fs::read_to_string(&output_path).unwrap();
700 assert!(content.contains("export type User = string;"));
701 }
702
703 #[test]
708 fn test_module_to_path() {
709 assert_eq!(
710 Generator::module_to_path("my_crate::models::user"),
711 PathBuf::from("models/user.ts")
712 );
713 assert_eq!(
714 Generator::module_to_path("my_crate::api"),
715 PathBuf::from("api.ts")
716 );
717 assert_eq!(
718 Generator::module_to_path("my_crate::nested::deep::module"),
719 PathBuf::from("nested/deep/module.ts")
720 );
721 }
722
723 #[test]
724 fn test_types_by_module() {
725 let mut generator = Generator::with_defaults();
726
727 generator.add(TypeDef::Named {
728 namespace: vec![],
729 name: "User".to_string(),
730 def: Box::new(TypeDef::Primitive(Primitive::String)),
731 module: Some("my_crate::models".to_string()),
732 });
733 generator.add(TypeDef::Named {
734 namespace: vec![],
735 name: "Post".to_string(),
736 def: Box::new(TypeDef::Primitive(Primitive::String)),
737 module: Some("my_crate::models".to_string()),
738 });
739 generator.add(TypeDef::Named {
740 namespace: vec![],
741 name: "Request".to_string(),
742 def: Box::new(TypeDef::Primitive(Primitive::String)),
743 module: Some("my_crate::api".to_string()),
744 });
745 generator.add(TypeDef::Named {
746 namespace: vec![],
747 name: "Orphan".to_string(),
748 def: Box::new(TypeDef::Primitive(Primitive::String)),
749 module: None,
750 });
751
752 let by_module = generator.types_by_module();
753
754 assert_eq!(by_module.len(), 3);
755 assert!(by_module.get("my_crate::models").unwrap().contains(&"User".to_string()));
756 assert!(by_module.get("my_crate::models").unwrap().contains(&"Post".to_string()));
757 assert!(by_module.get("my_crate::api").unwrap().contains(&"Request".to_string()));
758 assert!(by_module.get("default").unwrap().contains(&"Orphan".to_string()));
759 }
760
761 #[test]
762 fn test_generate_for_module() {
763 let mut generator = Generator::with_defaults();
764
765 generator.add(TypeDef::Named {
766 namespace: vec![],
767 name: "User".to_string(),
768 def: Box::new(TypeDef::Primitive(Primitive::String)),
769 module: Some("my_crate::models".to_string()),
770 });
771 generator.add(TypeDef::Named {
772 namespace: vec![],
773 name: "Post".to_string(),
774 def: Box::new(TypeDef::Primitive(Primitive::Number)),
775 module: Some("my_crate::models".to_string()),
776 });
777
778 let output = generator.generate_for_module("my_crate::models", &["User".to_string(), "Post".to_string()]);
779
780 assert!(output.contains("// Module: my_crate::models"));
781 assert!(output.contains("export type User = string;"));
782 assert!(output.contains("export type Post = number;"));
783 }
784
785 #[test]
786 fn test_write_multi_file() {
787 let temp_dir = tempfile::tempdir().unwrap();
788
789 let mut generator = Generator::with_defaults();
790
791 generator.add(TypeDef::Named {
792 namespace: vec![],
793 name: "User".to_string(),
794 def: Box::new(TypeDef::Primitive(Primitive::String)),
795 module: Some("my_crate::models::user".to_string()),
796 });
797 generator.add(TypeDef::Named {
798 namespace: vec![],
799 name: "Request".to_string(),
800 def: Box::new(TypeDef::Primitive(Primitive::String)),
801 module: Some("my_crate::api".to_string()),
802 });
803
804 let count = generator.write_multi_file(temp_dir.path()).unwrap();
805 assert_eq!(count, 2);
806
807 let user_path = temp_dir.path().join("models/user.ts");
809 let api_path = temp_dir.path().join("api.ts");
810
811 assert!(user_path.exists());
812 assert!(api_path.exists());
813
814 let user_content = std::fs::read_to_string(&user_path).unwrap();
816 assert!(user_content.contains("export type User = string;"));
817
818 let api_content = std::fs::read_to_string(&api_path).unwrap();
819 assert!(api_content.contains("export type Request = string;"));
820 }
821
822 #[test]
823 fn test_write_multi_file_if_changed() {
824 let temp_dir = tempfile::tempdir().unwrap();
825
826 let mut generator = Generator::with_defaults();
827 generator.add(TypeDef::Named {
828 namespace: vec![],
829 name: "User".to_string(),
830 def: Box::new(TypeDef::Primitive(Primitive::String)),
831 module: Some("my_crate::models".to_string()),
832 });
833
834 let count1 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
836 assert_eq!(count1, 1);
837
838 let count2 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
840 assert_eq!(count2, 0);
841
842 generator.add(TypeDef::Named {
844 namespace: vec![],
845 name: "Post".to_string(),
846 def: Box::new(TypeDef::Primitive(Primitive::Number)),
847 module: Some("my_crate::models".to_string()),
848 });
849
850 let count3 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
852 assert_eq!(count3, 1);
853 }
854
855 #[test]
856 fn test_write_multi_file_default_module() {
857 let temp_dir = tempfile::tempdir().unwrap();
858
859 let mut generator = Generator::with_defaults();
860 generator.add(TypeDef::Named {
861 namespace: vec![],
862 name: "Orphan".to_string(),
863 def: Box::new(TypeDef::Primitive(Primitive::String)),
864 module: None,
865 });
866
867 generator.write_multi_file(temp_dir.path()).unwrap();
868
869 let types_path = temp_dir.path().join("types.ts");
871 assert!(types_path.exists());
872
873 let content = std::fs::read_to_string(&types_path).unwrap();
874 assert!(content.contains("export type Orphan = string;"));
875 }
876}