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 `TypeScript` trait.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ferro_type::TypeScript;
10//! use ferro_type_gen::{Config, Generator, ExportStyle};
11//!
12//! #[derive(TypeScript)]
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, TypeScript};
48use std::collections::HashMap;
49use std::path::{Path, PathBuf};
50
51/// How to export types in the generated file
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum ExportStyle {
54    /// No export keyword: `type Foo = ...`
55    None,
56    /// Named exports: `export type Foo = ...` (default)
57    #[default]
58    Named,
59    /// Export as grouped object at end: `export { Foo, Bar }`
60    Grouped,
61}
62
63/// Configuration for TypeScript generation
64#[derive(Debug, Clone, Default)]
65pub struct Config {
66    /// Output file path (required for file-based generation)
67    pub output: Option<PathBuf>,
68
69    /// Export style for generated types
70    pub export_style: ExportStyle,
71
72    /// Whether to generate .d.ts (declarations only) vs .ts
73    pub declaration_only: bool,
74
75    /// Custom header comment to prepend
76    pub header: Option<String>,
77
78    /// Whether to add ESM-style .js extensions to imports
79    /// (for future multi-file mode)
80    pub esm_extensions: bool,
81}
82
83impl Config {
84    /// Create a new config with defaults
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Set the output file path
90    pub fn output(mut self, path: impl AsRef<Path>) -> Self {
91        self.output = Some(path.as_ref().to_owned());
92        self
93    }
94
95    /// Set the export style
96    pub fn export_style(mut self, style: ExportStyle) -> Self {
97        self.export_style = style;
98        self
99    }
100
101    /// Generate .d.ts declaration file instead of .ts
102    pub fn declaration_only(mut self) -> Self {
103        self.declaration_only = true;
104        self
105    }
106
107    /// Set a custom header comment
108    pub fn header(mut self, header: impl Into<String>) -> Self {
109        self.header = Some(header.into());
110        self
111    }
112
113    /// Enable ESM-style .js extensions in imports (for future multi-file mode)
114    pub fn esm_extensions(mut self) -> Self {
115        self.esm_extensions = true;
116        self
117    }
118}
119
120/// TypeScript file generator
121///
122/// Collects types and generates TypeScript definition files.
123#[derive(Debug)]
124pub struct Generator {
125    config: Config,
126    registry: TypeRegistry,
127}
128
129impl Generator {
130    /// Create a new generator with the given config
131    pub fn new(config: Config) -> Self {
132        Self {
133            config,
134            registry: TypeRegistry::new(),
135        }
136    }
137
138    /// Create a new generator with default config
139    pub fn with_defaults() -> Self {
140        Self::new(Config::default())
141    }
142
143    /// Register a type for generation
144    ///
145    /// The type must implement the `TypeScript` trait (usually via derive).
146    /// Returns `&mut Self` for method chaining.
147    pub fn register<T: TypeScript>(&mut self) -> &mut Self {
148        self.registry.register::<T>();
149        self
150    }
151
152    /// Add a TypeDef directly to the registry
153    ///
154    /// Useful when you have a TypeDef from another source.
155    pub fn add(&mut self, typedef: TypeDef) -> &mut Self {
156        self.registry.add_typedef(typedef);
157        self
158    }
159
160    /// Get a reference to the internal registry
161    pub fn registry(&self) -> &TypeRegistry {
162        &self.registry
163    }
164
165    /// Get a mutable reference to the internal registry
166    pub fn registry_mut(&mut self) -> &mut TypeRegistry {
167        &mut self.registry
168    }
169
170    /// Generate TypeScript and return as string
171    pub fn generate(&self) -> String {
172        let mut output = String::new();
173
174        // Header comment
175        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        // Types in dependency order
186        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                // Render without exports
195                output.push_str(&self.registry.render());
196                // Add grouped export at end
197                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    /// Generate TypeScript to the configured output file
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if:
214    /// - No output path is configured
215    /// - The file cannot be written
216    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        // Create parent directories
224        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    /// Write only if content has changed
235    ///
236    /// Returns `Ok(true)` if the file was written, `Ok(false)` if unchanged.
237    /// This is useful in build.rs to avoid unnecessary rebuilds.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if:
242    /// - No output path is configured
243    /// - The file cannot be read or written
244    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        // Check if file exists and has same content
254        if output_path.exists() {
255            let existing = std::fs::read_to_string(output_path)?;
256            if existing == new_content {
257                return Ok(false); // No change
258            }
259        }
260
261        // Create parent directories and write
262        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) // Changed
269    }
270
271    // ========================================================================
272    // MULTI-FILE GENERATION
273    // ========================================================================
274
275    /// Group types by their module path
276    ///
277    /// Returns a map from module path to list of type names in that module.
278    /// Types without a module path are grouped under "default".
279    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    /// Convert a module path to a file path
298    ///
299    /// For example:
300    /// - `my_crate::models::user` -> `models/user.ts`
301    /// - `my_crate::api::requests` -> `api/requests.ts`
302    ///
303    /// The crate name is stripped from the beginning.
304    pub fn module_to_path(module: &str) -> PathBuf {
305        // Split by :: and skip the crate name (first segment)
306        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    /// Generate TypeScript content for a specific module
322    ///
323    /// Only includes types from the specified module.
324    pub fn generate_for_module(&self, module: &str, type_names: &[String]) -> String {
325        let mut output = String::new();
326
327        // Header comment
328        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        // Get types for this module in dependency order
342        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        // TODO: Add import statements for types from other modules
349
350        // Render types
351        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    /// Write TypeScript to multiple files, organized by module
367    ///
368    /// Types are grouped by their module path and written to corresponding files.
369    /// For example:
370    /// - Types from `my_crate::models::user` go to `<output_dir>/models/user.ts`
371    /// - Types from `my_crate::api` go to `<output_dir>/api.ts`
372    ///
373    /// # Arguments
374    ///
375    /// * `output_dir` - Base directory for output files
376    ///
377    /// # Returns
378    ///
379    /// Returns the number of files written.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error if files cannot be written.
384    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            // Create parent directories
397            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    /// Write multi-file only if content has changed
412    ///
413    /// Returns the number of files that were written (changed).
414    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            // Check if file exists and has same content
429            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                // Create parent directories
438                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
458// ============================================================================
459// CONVENIENCE FUNCTIONS
460// ============================================================================
461
462/// Generate TypeScript for a single type
463///
464/// Returns the TypeScript definition as a string.
465pub fn generate<T: TypeScript>() -> String {
466    let mut generator = Generator::with_defaults();
467    generator.register::<T>();
468    generator.generate()
469}
470
471/// Export types from a registry to a file
472///
473/// Convenience function for simple use cases.
474pub fn export_to_file<P: AsRef<Path>>(path: P, registry: &TypeRegistry) -> std::io::Result<()> {
475    let content = registry.render_exported();
476
477    // Create parent directories
478    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// ============================================================================
489// TESTS
490// ============================================================================
491
492#[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        // Register string type (primitive, no named type added)
518        generator.register::<String>();
519
520        // Registry should be empty since String doesn't create a Named type
521        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        // First write should return true (changed)
651        assert!(generator.write_if_changed().unwrap());
652
653        // Second write should return false (unchanged)
654        assert!(!generator.write_if_changed().unwrap());
655
656        // Add another type
657        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        // Third write should return true (changed)
665        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        // generate() with a primitive type
679        let output = generate::<String>();
680        // String is primitive, doesn't produce named types
681        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, &registry).unwrap();
698
699        let content = std::fs::read_to_string(&output_path).unwrap();
700        assert!(content.contains("export type User = string;"));
701    }
702
703    // ========================================================================
704    // MULTI-FILE TESTS
705    // ========================================================================
706
707    #[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        // Check files exist
808        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        // Check content
815        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        // First write should write
835        let count1 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
836        assert_eq!(count1, 1);
837
838        // Second write should not write (unchanged)
839        let count2 = generator.write_multi_file_if_changed(temp_dir.path()).unwrap();
840        assert_eq!(count2, 0);
841
842        // Add another type
843        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        // Third write should write (changed)
851        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        // Types without module go to types.ts
870        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}