1use ferro_type::{TypeDef, TypeRegistry, TS};
48use std::collections::HashMap;
49use std::path::{Path, PathBuf};
50
51pub const PRETTIFY_TYPE: &str = "type Prettify<T> = { [K in keyof T]: T[K] } & {};";
72
73pub const PRETTIFY_TYPE_EXPORTED: &str = "export type Prettify<T> = { [K in keyof T]: T[K] } & {};";
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum ExportStyle {
79 None,
81 #[default]
83 Named,
84 Grouped,
86}
87
88#[derive(Debug, Clone, Default)]
90pub struct Config {
91 pub output: Option<PathBuf>,
93
94 pub export_style: ExportStyle,
96
97 pub declaration_only: bool,
99
100 pub header: Option<String>,
102
103 pub esm_extensions: bool,
106
107 pub include_utilities: bool,
109}
110
111impl Config {
112 pub fn new() -> Self {
114 Self::default()
115 }
116
117 pub fn output(mut self, path: impl AsRef<Path>) -> Self {
119 self.output = Some(path.as_ref().to_owned());
120 self
121 }
122
123 pub fn export_style(mut self, style: ExportStyle) -> Self {
125 self.export_style = style;
126 self
127 }
128
129 pub fn declaration_only(mut self) -> Self {
131 self.declaration_only = true;
132 self
133 }
134
135 pub fn header(mut self, header: impl Into<String>) -> Self {
137 self.header = Some(header.into());
138 self
139 }
140
141 pub fn esm_extensions(mut self) -> Self {
143 self.esm_extensions = true;
144 self
145 }
146
147 pub fn include_utilities(mut self) -> Self {
149 self.include_utilities = true;
150 self
151 }
152}
153
154#[derive(Debug)]
158pub struct Generator {
159 config: Config,
160 registry: TypeRegistry,
161}
162
163impl Generator {
164 pub fn new(config: Config) -> Self {
166 Self {
167 config,
168 registry: TypeRegistry::new(),
169 }
170 }
171
172 pub fn with_defaults() -> Self {
174 Self::new(Config::default())
175 }
176
177 pub fn register<T: TS>(&mut self) -> &mut Self {
182 self.registry.register::<T>();
183 self
184 }
185
186 pub fn add(&mut self, typedef: TypeDef) -> &mut Self {
190 self.registry.add_typedef(typedef);
191 self
192 }
193
194 pub fn registry(&self) -> &TypeRegistry {
196 &self.registry
197 }
198
199 pub fn registry_mut(&mut self) -> &mut TypeRegistry {
201 &mut self.registry
202 }
203
204 pub fn generate(&self) -> String {
206 let mut output = String::new();
207
208 if let Some(ref header) = self.config.header {
210 output.push_str("// ");
211 output.push_str(header);
212 output.push('\n');
213 } else {
214 output.push_str("// Generated by ferro-type-gen\n");
215 output.push_str("// Do not edit manually\n");
216 }
217 output.push('\n');
218
219 if self.config.include_utilities {
221 match self.config.export_style {
222 ExportStyle::None => {
223 output.push_str(PRETTIFY_TYPE);
224 }
225 ExportStyle::Named | ExportStyle::Grouped => {
226 output.push_str(PRETTIFY_TYPE_EXPORTED);
227 }
228 }
229 output.push_str("\n\n");
230 }
231
232 match self.config.export_style {
234 ExportStyle::None => {
235 output.push_str(&self.registry.render());
236 }
237 ExportStyle::Named => {
238 output.push_str(&self.registry.render_exported());
239 }
240 ExportStyle::Grouped => {
241 output.push_str(&self.registry.render());
243 let names: Vec<_> = self.registry.sorted_types().into_iter().collect();
245 if !names.is_empty() {
246 output.push_str("\nexport { ");
247 output.push_str(&names.join(", "));
248 output.push_str(" };\n");
249 }
250 }
251 }
252
253 output
254 }
255
256 pub fn write(&self) -> std::io::Result<()> {
264 let output_path = self
265 .config
266 .output
267 .as_ref()
268 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "No output path configured"))?;
269
270 if let Some(parent) = output_path.parent() {
272 if !parent.as_os_str().is_empty() {
273 std::fs::create_dir_all(parent)?;
274 }
275 }
276
277 let content = self.generate();
278 std::fs::write(output_path, content)
279 }
280
281 pub fn write_if_changed(&self) -> std::io::Result<bool> {
292 let output_path = self
293 .config
294 .output
295 .as_ref()
296 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "No output path configured"))?;
297
298 let new_content = self.generate();
299
300 if output_path.exists() {
302 let existing = std::fs::read_to_string(output_path)?;
303 if existing == new_content {
304 return Ok(false); }
306 }
307
308 if let Some(parent) = output_path.parent() {
310 if !parent.as_os_str().is_empty() {
311 std::fs::create_dir_all(parent)?;
312 }
313 }
314 std::fs::write(output_path, new_content)?;
315 Ok(true) }
317
318 pub fn types_by_module(&self) -> HashMap<String, Vec<String>> {
327 let mut result: HashMap<String, Vec<String>> = HashMap::new();
328
329 for name in self.registry.type_names() {
330 if let Some(typedef) = self.registry.get(name) {
331 let module = match typedef {
332 TypeDef::Named { module, .. } => {
333 module.clone().unwrap_or_else(|| "default".to_string())
334 }
335 _ => "default".to_string(),
336 };
337 result.entry(module).or_default().push(name.to_string());
338 }
339 }
340
341 result
342 }
343
344 pub fn module_to_path(module: &str) -> PathBuf {
352 let parts: Vec<&str> = module.split("::").collect();
354 let path_parts = if parts.len() > 1 {
355 &parts[1..]
356 } else {
357 &parts[..]
358 };
359
360 let mut path = PathBuf::new();
361 for part in path_parts {
362 path.push(part);
363 }
364 path.set_extension("ts");
365 path
366 }
367
368 pub fn generate_for_module(&self, module: &str, type_names: &[String]) -> String {
372 let mut output = String::new();
373
374 if let Some(ref header) = self.config.header {
376 output.push_str("// ");
377 output.push_str(header);
378 output.push('\n');
379 } else {
380 output.push_str("// Generated by ferro-type-gen\n");
381 output.push_str("// Do not edit manually\n");
382 output.push_str("// Module: ");
383 output.push_str(module);
384 output.push('\n');
385 }
386 output.push('\n');
387
388 let sorted = self.registry.sorted_types();
390 let module_types: Vec<_> = sorted
391 .into_iter()
392 .filter(|name| type_names.contains(&name.to_string()))
393 .collect();
394
395 for name in module_types {
399 if let Some(typedef) = self.registry.get(name) {
400 if let TypeDef::Named { name, def, .. } = typedef {
401 let export_prefix = match self.config.export_style {
402 ExportStyle::None => "",
403 ExportStyle::Named | ExportStyle::Grouped => "export ",
404 };
405 output.push_str(&format!("{}type {} = {};\n\n", export_prefix, name, def.render()));
406 }
407 }
408 }
409
410 output
411 }
412
413 pub fn write_multi_file(&self, output_dir: impl AsRef<Path>) -> std::io::Result<usize> {
432 let output_dir = output_dir.as_ref();
433 let types_by_module = self.types_by_module();
434 let mut count = 0;
435
436 for (module, type_names) in &types_by_module {
437 let file_path = if module == "default" {
438 output_dir.join("types.ts")
439 } else {
440 output_dir.join(Self::module_to_path(module))
441 };
442
443 if let Some(parent) = file_path.parent() {
445 if !parent.as_os_str().is_empty() {
446 std::fs::create_dir_all(parent)?;
447 }
448 }
449
450 let content = self.generate_for_module(module, type_names);
451 std::fs::write(&file_path, content)?;
452 count += 1;
453 }
454
455 Ok(count)
456 }
457
458 pub fn write_multi_file_if_changed(&self, output_dir: impl AsRef<Path>) -> std::io::Result<usize> {
462 let output_dir = output_dir.as_ref();
463 let types_by_module = self.types_by_module();
464 let mut count = 0;
465
466 for (module, type_names) in &types_by_module {
467 let file_path = if module == "default" {
468 output_dir.join("types.ts")
469 } else {
470 output_dir.join(Self::module_to_path(module))
471 };
472
473 let new_content = self.generate_for_module(module, type_names);
474
475 let should_write = if file_path.exists() {
477 let existing = std::fs::read_to_string(&file_path)?;
478 existing != new_content
479 } else {
480 true
481 };
482
483 if should_write {
484 if let Some(parent) = file_path.parent() {
486 if !parent.as_os_str().is_empty() {
487 std::fs::create_dir_all(parent)?;
488 }
489 }
490 std::fs::write(&file_path, new_content)?;
491 count += 1;
492 }
493 }
494
495 Ok(count)
496 }
497}
498
499impl Default for Generator {
500 fn default() -> Self {
501 Self::with_defaults()
502 }
503}
504
505pub fn generate<T: TS>() -> String {
513 let mut generator = Generator::with_defaults();
514 generator.register::<T>();
515 generator.generate()
516}
517
518pub fn export_to_file<P: AsRef<Path>>(path: P, registry: &TypeRegistry) -> std::io::Result<()> {
522 let content = registry.render_exported();
523
524 let path = path.as_ref();
526 if let Some(parent) = path.parent() {
527 if !parent.as_os_str().is_empty() {
528 std::fs::create_dir_all(parent)?;
529 }
530 }
531
532 std::fs::write(path, content)
533}
534
535#[cfg(test)]
540mod tests {
541 use super::*;
542 use ferro_type::{Field, Primitive, TypeDef};
543
544 #[test]
545 fn test_config_builder() {
546 let config = Config::new()
547 .output("types.ts")
548 .export_style(ExportStyle::Named)
549 .header("Custom header")
550 .declaration_only()
551 .esm_extensions();
552
553 assert_eq!(config.output, Some(PathBuf::from("types.ts")));
554 assert_eq!(config.export_style, ExportStyle::Named);
555 assert_eq!(config.header, Some("Custom header".to_string()));
556 assert!(config.declaration_only);
557 assert!(config.esm_extensions);
558 }
559
560 #[test]
561 fn test_generator_register() {
562 let mut generator = Generator::with_defaults();
563
564 generator.register::<String>();
566
567 assert_eq!(generator.registry().len(), 0);
569 }
570
571 #[test]
572 fn test_generator_add_typedef() {
573 let mut generator = Generator::with_defaults();
574
575 let user_type = TypeDef::Named {
576 namespace: vec![],
577 name: "User".to_string(),
578 def: Box::new(TypeDef::Object(vec![
579 Field::new("id", TypeDef::Primitive(Primitive::String)),
580 Field::new("name", TypeDef::Primitive(Primitive::String)),
581 ])),
582 module: None,
583 wrapper: None,
584 };
585
586 generator.add(user_type);
587
588 assert_eq!(generator.registry().len(), 1);
589 assert!(generator.registry().get("User").is_some());
590 }
591
592 #[test]
593 fn test_generate_export_none() {
594 let mut generator = Generator::new(Config::new().export_style(ExportStyle::None));
595
596 generator.add(TypeDef::Named {
597 namespace: vec![],
598 name: "User".to_string(),
599 def: Box::new(TypeDef::Primitive(Primitive::String)),
600 module: None,
601 wrapper: None,
602 });
603
604 let output = generator.generate();
605 assert!(output.contains("type User = string;"));
606 assert!(!output.contains("export type User"));
607 }
608
609 #[test]
610 fn test_generate_export_named() {
611 let mut generator = Generator::new(Config::new().export_style(ExportStyle::Named));
612
613 generator.add(TypeDef::Named {
614 namespace: vec![],
615 name: "User".to_string(),
616 def: Box::new(TypeDef::Primitive(Primitive::String)),
617 module: None,
618 wrapper: None,
619 });
620
621 let output = generator.generate();
622 assert!(output.contains("export type User = string;"));
623 }
624
625 #[test]
626 fn test_generate_export_grouped() {
627 let mut generator = Generator::new(Config::new().export_style(ExportStyle::Grouped));
628
629 generator.add(TypeDef::Named {
630 namespace: vec![],
631 name: "User".to_string(),
632 def: Box::new(TypeDef::Primitive(Primitive::String)),
633 module: None,
634 wrapper: None,
635 });
636 generator.add(TypeDef::Named {
637 namespace: vec![],
638 name: "Post".to_string(),
639 def: Box::new(TypeDef::Primitive(Primitive::String)),
640 module: None,
641 wrapper: None,
642 });
643
644 let output = generator.generate();
645 assert!(output.contains("type User = string;"));
646 assert!(output.contains("type Post = string;"));
647 assert!(output.contains("export { "));
648 assert!(output.contains("User"));
649 assert!(output.contains("Post"));
650 }
651
652 #[test]
653 fn test_generate_custom_header() {
654 let generator = Generator::new(Config::new().header("My custom header"));
655
656 let output = generator.generate();
657 assert!(output.starts_with("// My custom header\n"));
658 }
659
660 #[test]
661 fn test_generate_default_header() {
662 let generator = Generator::with_defaults();
663
664 let output = generator.generate();
665 assert!(output.contains("// Generated by ferro-type-gen"));
666 assert!(output.contains("// Do not edit manually"));
667 }
668
669 #[test]
670 fn test_include_utilities() {
671 let generator = Generator::new(Config::new().include_utilities());
672
673 let output = generator.generate();
674 assert!(output.contains("export type Prettify<T>"));
675 assert!(output.contains("{ [K in keyof T]: T[K] }"));
676 }
677
678 #[test]
679 fn test_include_utilities_no_export() {
680 let generator = Generator::new(
681 Config::new()
682 .export_style(ExportStyle::None)
683 .include_utilities()
684 );
685
686 let output = generator.generate();
687 assert!(output.contains("type Prettify<T>"));
688 assert!(!output.contains("export type Prettify"));
689 }
690
691 #[test]
692 fn test_no_utilities_by_default() {
693 let generator = Generator::with_defaults();
694
695 let output = generator.generate();
696 assert!(!output.contains("Prettify"));
697 }
698
699 #[test]
700 fn test_write_creates_parent_dirs() {
701 let temp_dir = tempfile::tempdir().unwrap();
702 let output_path = temp_dir.path().join("nested/dir/types.ts");
703
704 let mut generator = Generator::new(Config::new().output(&output_path));
705 generator.add(TypeDef::Named {
706 namespace: vec![],
707 name: "User".to_string(),
708 def: Box::new(TypeDef::Primitive(Primitive::String)),
709 module: None,
710 wrapper: None,
711 });
712
713 generator.write().unwrap();
714
715 assert!(output_path.exists());
716 let content = std::fs::read_to_string(&output_path).unwrap();
717 assert!(content.contains("export type User = string;"));
718 }
719
720 #[test]
721 fn test_write_if_changed() {
722 let temp_dir = tempfile::tempdir().unwrap();
723 let output_path = temp_dir.path().join("types.ts");
724
725 let mut generator = Generator::new(Config::new().output(&output_path));
726 generator.add(TypeDef::Named {
727 namespace: vec![],
728 name: "User".to_string(),
729 def: Box::new(TypeDef::Primitive(Primitive::String)),
730 module: None,
731 wrapper: None,
732 });
733
734 assert!(generator.write_if_changed().unwrap());
736
737 assert!(!generator.write_if_changed().unwrap());
739
740 generator.add(TypeDef::Named {
742 namespace: vec![],
743 name: "Post".to_string(),
744 def: Box::new(TypeDef::Primitive(Primitive::String)),
745 module: None,
746 wrapper: None,
747 });
748
749 assert!(generator.write_if_changed().unwrap());
751 }
752
753 #[test]
754 fn test_write_no_output_configured() {
755 let generator = Generator::with_defaults();
756 let result = generator.write();
757 assert!(result.is_err());
758 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
759 }
760
761 #[test]
762 fn test_convenience_generate() {
763 let output = generate::<String>();
765 assert!(output.contains("// Generated by ferro-type-gen"));
767 }
768
769 #[test]
770 fn test_convenience_export_to_file() {
771 let temp_dir = tempfile::tempdir().unwrap();
772 let output_path = temp_dir.path().join("types.ts");
773
774 let mut registry = TypeRegistry::new();
775 registry.add_typedef(TypeDef::Named {
776 namespace: vec![],
777 name: "User".to_string(),
778 def: Box::new(TypeDef::Primitive(Primitive::String)),
779 module: None,
780 wrapper: None,
781 });
782
783 export_to_file(&output_path, ®istry).unwrap();
784
785 let content = std::fs::read_to_string(&output_path).unwrap();
786 assert!(content.contains("export type User = string;"));
787 }
788
789 #[test]
794 fn test_module_to_path() {
795 assert_eq!(
796 Generator::module_to_path("my_crate::models::user"),
797 PathBuf::from("models/user.ts")
798 );
799 assert_eq!(
800 Generator::module_to_path("my_crate::api"),
801 PathBuf::from("api.ts")
802 );
803 assert_eq!(
804 Generator::module_to_path("my_crate::nested::deep::module"),
805 PathBuf::from("nested/deep/module.ts")
806 );
807 }
808
809 #[test]
810 fn test_types_by_module() {
811 let mut generator = Generator::with_defaults();
812
813 generator.add(TypeDef::Named {
814 namespace: vec![],
815 name: "User".to_string(),
816 def: Box::new(TypeDef::Primitive(Primitive::String)),
817 module: Some("my_crate::models".to_string()),
818 wrapper: None,
819 });
820 generator.add(TypeDef::Named {
821 namespace: vec![],
822 name: "Post".to_string(),
823 def: Box::new(TypeDef::Primitive(Primitive::String)),
824 module: Some("my_crate::models".to_string()),
825 wrapper: None,
826 });
827 generator.add(TypeDef::Named {
828 namespace: vec![],
829 name: "Request".to_string(),
830 def: Box::new(TypeDef::Primitive(Primitive::String)),
831 module: Some("my_crate::api".to_string()),
832 wrapper: None,
833 });
834 generator.add(TypeDef::Named {
835 namespace: vec![],
836 name: "Orphan".to_string(),
837 def: Box::new(TypeDef::Primitive(Primitive::String)),
838 module: None,
839 wrapper: None,
840 });
841
842 let by_module = generator.types_by_module();
843
844 assert_eq!(by_module.len(), 3);
845 assert!(by_module.get("my_crate::models").unwrap().contains(&"User".to_string()));
846 assert!(by_module.get("my_crate::models").unwrap().contains(&"Post".to_string()));
847 assert!(by_module.get("my_crate::api").unwrap().contains(&"Request".to_string()));
848 assert!(by_module.get("default").unwrap().contains(&"Orphan".to_string()));
849 }
850
851 #[test]
852 fn test_generate_for_module() {
853 let mut generator = Generator::with_defaults();
854
855 generator.add(TypeDef::Named {
856 namespace: vec![],
857 name: "User".to_string(),
858 def: Box::new(TypeDef::Primitive(Primitive::String)),
859 module: Some("my_crate::models".to_string()),
860 wrapper: None,
861 });
862 generator.add(TypeDef::Named {
863 namespace: vec![],
864 name: "Post".to_string(),
865 def: Box::new(TypeDef::Primitive(Primitive::Number)),
866 module: Some("my_crate::models".to_string()),
867 wrapper: None,
868 });
869
870 let output = generator.generate_for_module("my_crate::models", &["User".to_string(), "Post".to_string()]);
871
872 assert!(output.contains("// Module: my_crate::models"));
873 assert!(output.contains("export type User = string;"));
874 assert!(output.contains("export type Post = number;"));
875 }
876
877 #[test]
878 fn test_write_multi_file() {
879 let temp_dir = tempfile::tempdir().unwrap();
880
881 let mut generator = Generator::with_defaults();
882
883 generator.add(TypeDef::Named {
884 namespace: vec![],
885 name: "User".to_string(),
886 def: Box::new(TypeDef::Primitive(Primitive::String)),
887 module: Some("my_crate::models::user".to_string()),
888 wrapper: None,
889 });
890 generator.add(TypeDef::Named {
891 namespace: vec![],
892 name: "Request".to_string(),
893 def: Box::new(TypeDef::Primitive(Primitive::String)),
894 module: Some("my_crate::api".to_string()),
895 wrapper: None,
896 });
897
898 let count = generator.write_multi_file(temp_dir.path()).unwrap();
899 assert_eq!(count, 2);
900
901 let user_path = temp_dir.path().join("models/user.ts");
903 let api_path = temp_dir.path().join("api.ts");
904
905 assert!(user_path.exists());
906 assert!(api_path.exists());
907
908 let user_content = std::fs::read_to_string(&user_path).unwrap();
910 assert!(user_content.contains("export type User = string;"));
911
912 let api_content = std::fs::read_to_string(&api_path).unwrap();
913 assert!(api_content.contains("export type Request = string;"));
914 }
915
916 #[test]
917 fn test_write_multi_file_if_changed() {
918 let temp_dir = tempfile::tempdir().unwrap();
919
920 let mut generator = Generator::with_defaults();
921 generator.add(TypeDef::Named {
922 namespace: vec![],
923 name: "User".to_string(),
924 def: Box::new(TypeDef::Primitive(Primitive::String)),
925 module: Some("my_crate::models".to_string()),
926 wrapper: None,
927 });
928
929 let count1 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
931 assert_eq!(count1, 1);
932
933 let count2 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
935 assert_eq!(count2, 0);
936
937 generator.add(TypeDef::Named {
939 namespace: vec![],
940 name: "Post".to_string(),
941 def: Box::new(TypeDef::Primitive(Primitive::Number)),
942 module: Some("my_crate::models".to_string()),
943 wrapper: None,
944 });
945
946 let count3 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
948 assert_eq!(count3, 1);
949 }
950
951 #[test]
952 fn test_write_multi_file_default_module() {
953 let temp_dir = tempfile::tempdir().unwrap();
954
955 let mut generator = Generator::with_defaults();
956 generator.add(TypeDef::Named {
957 namespace: vec![],
958 name: "Orphan".to_string(),
959 def: Box::new(TypeDef::Primitive(Primitive::String)),
960 module: None,
961 wrapper: None,
962 });
963
964 generator.write_multi_file(temp_dir.path()).unwrap();
965
966 let types_path = temp_dir.path().join("types.ts");
968 assert!(types_path.exists());
969
970 let content = std::fs::read_to_string(&types_path).unwrap();
971 assert!(content.contains("export type Orphan = string;"));
972 }
973}