ferro_type_gen/
lib.rs

1//! TypeScript file generation for ferro-type
2//!
3//! This crate provides utilities for generating TypeScript definition files
4//! from Rust types that implement the `TS` trait.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ferro_type::TS;
10//! use ferro_type_gen::{Config, Generator, ExportStyle};
11//!
12//! #[derive(TS)]
13//! struct User {
14//!     id: String,
15//!     name: String,
16//! }
17//!
18//! let mut generator = Generator::new(
19//!     Config::new()
20//!         .output("types.ts")
21//!         .export_style(ExportStyle::Named)
22//! );
23//!
24//! generator.register::<User>();
25//! generator.write().expect("Failed to write TypeScript");
26//! ```
27//!
28//! # build.rs Integration
29//!
30//! ```ignore
31//! // build.rs
32//! use ferro_type_gen::{Config, Generator};
33//!
34//! fn main() {
35//!     let mut generator = Generator::new(
36//!         Config::new().output("../frontend/src/types/api.ts")
37//!     );
38//!
39//!     generator.register::<api::User>()
40//!        .register::<api::Post>();
41//!
42//!     generator.write_if_changed()
43//!         .expect("TypeScript generation failed");
44//! }
45//! ```
46
47use ferro_type::{TypeDef, TypeRegistry, TS};
48use std::collections::HashMap;
49use std::path::{Path, PathBuf};
50
51// ============================================================================
52// UTILITY TYPES
53// ============================================================================
54
55/// The Prettify utility type flattens intersection types for better readability.
56///
57/// Example usage in Rust:
58/// ```ignore
59/// #[derive(TypeScript)]
60/// #[ts(wrapper = "Prettify")]
61/// struct User {
62///     pub id: String,
63///     pub name: String,
64/// }
65/// ```
66///
67/// Generates:
68/// ```typescript
69/// type User = Prettify<{ id: string; name: string }>;
70/// ```
71pub const PRETTIFY_TYPE: &str = "type Prettify<T> = { [K in keyof T]: T[K] } & {};";
72
73/// Exported version of the Prettify utility type (with export keyword)
74pub const PRETTIFY_TYPE_EXPORTED: &str = "export type Prettify<T> = { [K in keyof T]: T[K] } & {};";
75
76/// How to export types in the generated file
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum ExportStyle {
79    /// No export keyword: `type Foo = ...`
80    None,
81    /// Named exports: `export type Foo = ...` (default)
82    #[default]
83    Named,
84    /// Export as grouped object at end: `export { Foo, Bar }`
85    Grouped,
86}
87
88/// Configuration for TypeScript generation
89#[derive(Debug, Clone, Default)]
90pub struct Config {
91    /// Output file path (required for file-based generation)
92    pub output: Option<PathBuf>,
93
94    /// Export style for generated types
95    pub export_style: ExportStyle,
96
97    /// Whether to generate .d.ts (declarations only) vs .ts
98    pub declaration_only: bool,
99
100    /// Custom header comment to prepend
101    pub header: Option<String>,
102
103    /// Whether to add ESM-style .js extensions to imports
104    /// (for future multi-file mode)
105    pub esm_extensions: bool,
106
107    /// Include common utility types (Prettify, etc.) in the output
108    pub include_utilities: bool,
109}
110
111impl Config {
112    /// Create a new config with defaults
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Set the output file path
118    pub fn output(mut self, path: impl AsRef<Path>) -> Self {
119        self.output = Some(path.as_ref().to_owned());
120        self
121    }
122
123    /// Set the export style
124    pub fn export_style(mut self, style: ExportStyle) -> Self {
125        self.export_style = style;
126        self
127    }
128
129    /// Generate .d.ts declaration file instead of .ts
130    pub fn declaration_only(mut self) -> Self {
131        self.declaration_only = true;
132        self
133    }
134
135    /// Set a custom header comment
136    pub fn header(mut self, header: impl Into<String>) -> Self {
137        self.header = Some(header.into());
138        self
139    }
140
141    /// Enable ESM-style .js extensions in imports (for future multi-file mode)
142    pub fn esm_extensions(mut self) -> Self {
143        self.esm_extensions = true;
144        self
145    }
146
147    /// Include common utility types (Prettify, etc.) in the generated output
148    pub fn include_utilities(mut self) -> Self {
149        self.include_utilities = true;
150        self
151    }
152}
153
154/// TypeScript file generator
155///
156/// Collects types and generates TypeScript definition files.
157#[derive(Debug)]
158pub struct Generator {
159    config: Config,
160    registry: TypeRegistry,
161}
162
163impl Generator {
164    /// Create a new generator with the given config
165    pub fn new(config: Config) -> Self {
166        Self {
167            config,
168            registry: TypeRegistry::new(),
169        }
170    }
171
172    /// Create a new generator with default config
173    pub fn with_defaults() -> Self {
174        Self::new(Config::default())
175    }
176
177    /// Register a type for generation
178    ///
179    /// The type must implement the `TS` trait (usually via derive).
180    /// Returns `&mut Self` for method chaining.
181    pub fn register<T: TS>(&mut self) -> &mut Self {
182        self.registry.register::<T>();
183        self
184    }
185
186    /// Add a TypeDef directly to the registry
187    ///
188    /// Useful when you have a TypeDef from another source.
189    pub fn add(&mut self, typedef: TypeDef) -> &mut Self {
190        self.registry.add_typedef(typedef);
191        self
192    }
193
194    /// Get a reference to the internal registry
195    pub fn registry(&self) -> &TypeRegistry {
196        &self.registry
197    }
198
199    /// Get a mutable reference to the internal registry
200    pub fn registry_mut(&mut self) -> &mut TypeRegistry {
201        &mut self.registry
202    }
203
204    /// Generate TypeScript and return as string
205    pub fn generate(&self) -> String {
206        let mut output = String::new();
207
208        // Header comment
209        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        // Utility types (if configured)
220        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        // Types in dependency order
233        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                // Render without exports
242                output.push_str(&self.registry.render());
243                // Add grouped export at end
244                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    /// Generate TypeScript to the configured output file
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if:
261    /// - No output path is configured
262    /// - The file cannot be written
263    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        // Create parent directories
271        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    /// Write only if content has changed
282    ///
283    /// Returns `Ok(true)` if the file was written, `Ok(false)` if unchanged.
284    /// This is useful in build.rs to avoid unnecessary rebuilds.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if:
289    /// - No output path is configured
290    /// - The file cannot be read or written
291    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        // Check if file exists and has same content
301        if output_path.exists() {
302            let existing = std::fs::read_to_string(output_path)?;
303            if existing == new_content {
304                return Ok(false); // No change
305            }
306        }
307
308        // Create parent directories and write
309        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) // Changed
316    }
317
318    // ========================================================================
319    // MULTI-FILE GENERATION
320    // ========================================================================
321
322    /// Group types by their module path
323    ///
324    /// Returns a map from module path to list of type names in that module.
325    /// Types without a module path are grouped under "default".
326    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    /// Convert a module path to a file path
345    ///
346    /// For example:
347    /// - `my_crate::models::user` -> `models/user.ts`
348    /// - `my_crate::api::requests` -> `api/requests.ts`
349    ///
350    /// The crate name is stripped from the beginning.
351    pub fn module_to_path(module: &str) -> PathBuf {
352        // Split by :: and skip the crate name (first segment)
353        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    /// Generate TypeScript content for a specific module
369    ///
370    /// Only includes types from the specified module.
371    pub fn generate_for_module(&self, module: &str, type_names: &[String]) -> String {
372        let mut output = String::new();
373
374        // Header comment
375        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        // Get types for this module in dependency order
389        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        // TODO: Add import statements for types from other modules
396
397        // Render types
398        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    /// Write TypeScript to multiple files, organized by module
414    ///
415    /// Types are grouped by their module path and written to corresponding files.
416    /// For example:
417    /// - Types from `my_crate::models::user` go to `<output_dir>/models/user.ts`
418    /// - Types from `my_crate::api` go to `<output_dir>/api.ts`
419    ///
420    /// # Arguments
421    ///
422    /// * `output_dir` - Base directory for output files
423    ///
424    /// # Returns
425    ///
426    /// Returns the number of files written.
427    ///
428    /// # Errors
429    ///
430    /// Returns an error if files cannot be written.
431    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            // Create parent directories
444            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    /// Write multi-file only if content has changed
459    ///
460    /// Returns the number of files that were written (changed).
461    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            // Check if file exists and has same content
476            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                // Create parent directories
485                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
505// ============================================================================
506// CONVENIENCE FUNCTIONS
507// ============================================================================
508
509/// Generate TypeScript for a single type
510///
511/// Returns the TypeScript definition as a string.
512pub fn generate<T: TS>() -> String {
513    let mut generator = Generator::with_defaults();
514    generator.register::<T>();
515    generator.generate()
516}
517
518/// Export types from a registry to a file
519///
520/// Convenience function for simple use cases.
521pub fn export_to_file<P: AsRef<Path>>(path: P, registry: &TypeRegistry) -> std::io::Result<()> {
522    let content = registry.render_exported();
523
524    // Create parent directories
525    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// ============================================================================
536// TESTS
537// ============================================================================
538
539#[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        // Register string type (primitive, no named type added)
565        generator.register::<String>();
566
567        // Registry should be empty since String doesn't create a Named type
568        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        // First write should return true (changed)
735        assert!(generator.write_if_changed().unwrap());
736
737        // Second write should return false (unchanged)
738        assert!(!generator.write_if_changed().unwrap());
739
740        // Add another type
741        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        // Third write should return true (changed)
750        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        // generate() with a primitive type
764        let output = generate::<String>();
765        // String is primitive, doesn't produce named types
766        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, &registry).unwrap();
784
785        let content = std::fs::read_to_string(&output_path).unwrap();
786        assert!(content.contains("export type User = string;"));
787    }
788
789    // ========================================================================
790    // MULTI-FILE TESTS
791    // ========================================================================
792
793    #[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        // Check files exist
902        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        // Check content
909        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        // First write should write
930        let count1 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
931        assert_eq!(count1, 1);
932
933        // Second write should not write (unchanged)
934        let count2 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
935        assert_eq!(count2, 0);
936
937        // Add another type
938        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        // Third write should write (changed)
947        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        // Types without module go to types.ts
967        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}