Skip to main content

ryo_source/generator/
multi.rs

1//! Multi-file generator.
2//!
3//! Generates code into multiple files following Rust module conventions.
4//! Supports both modern (`module.rs` + `module/`) and legacy (`module/mod.rs`) styles.
5
6use super::{GeneratedSource, ModuleTree};
7use crate::pure::{PureFile, PureItem, PureMod};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11/// Result of multi-file generation.
12#[derive(Debug, Clone, Default)]
13pub struct GeneratedFiles {
14    /// Map of relative file paths to generated source.
15    pub files: HashMap<PathBuf, GeneratedSource>,
16}
17
18impl GeneratedFiles {
19    /// Create empty GeneratedFiles.
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Get all file paths.
25    pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
26        self.files.keys()
27    }
28
29    /// Get a specific file.
30    pub fn get(&self, path: &Path) -> Option<&GeneratedSource> {
31        self.files.get(path)
32    }
33
34    /// Get number of files.
35    pub fn len(&self) -> usize {
36        self.files.len()
37    }
38
39    /// Check if empty.
40    pub fn is_empty(&self) -> bool {
41        self.files.is_empty()
42    }
43
44    /// Compute diff against existing PureFiles.
45    ///
46    /// Returns only the files that have changed (or are new).
47    /// Comparison is done on PureFile structure, not source text,
48    /// so formatting differences are ignored.
49    pub fn diff(&self, existing: &HashMap<PathBuf, PureFile>) -> GeneratedFiles {
50        let mut changed = GeneratedFiles::new();
51
52        for (path, generated) in &self.files {
53            let is_changed = match existing.get(path) {
54                None => true, // New file
55                Some(existing_pure) => {
56                    // Compare PureFile structures
57                    // We use Debug representation for structural comparison
58                    // This is not perfect but works for most cases
59                    format!("{:?}", generated.pure_file) != format!("{:?}", existing_pure)
60                }
61            };
62
63            if is_changed {
64                changed.files.insert(path.clone(), generated.clone());
65            }
66        }
67
68        changed
69    }
70
71    /// Get paths of files that would be deleted (exist in `existing` but not in generated).
72    pub fn deleted_paths<'a>(&self, existing: &'a HashMap<PathBuf, PureFile>) -> Vec<&'a PathBuf> {
73        existing
74            .keys()
75            .filter(|path| !self.files.contains_key(*path))
76            .collect()
77    }
78}
79
80/// Generator that outputs code into multiple files.
81///
82/// This generator follows Rust's module conventions:
83///
84/// **Modern style** (`use_mod_rs = false`):
85/// ```text
86/// src/
87///   lib.rs           # crate root
88///   models.rs        # mod models
89///   models/
90///     user.rs        # mod models::user
91///     post.rs        # mod models::post
92/// ```
93///
94/// **Legacy style** (`use_mod_rs = true`):
95/// ```text
96/// src/
97///   lib.rs           # crate root
98///   models/
99///     mod.rs         # mod models
100///     user.rs        # mod models::user
101///     post.rs        # mod models::post
102/// ```
103#[derive(Debug, Clone)]
104pub struct MultiFileGenerator {
105    /// Use mod.rs style (legacy) instead of module.rs style (modern).
106    pub use_mod_rs: bool,
107    /// Root file name (default: "lib.rs").
108    pub root_file: String,
109}
110
111impl Default for MultiFileGenerator {
112    fn default() -> Self {
113        Self {
114            use_mod_rs: false,
115            root_file: "lib.rs".to_string(),
116        }
117    }
118}
119
120impl MultiFileGenerator {
121    /// Create a new multi-file generator with modern style.
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Use legacy mod.rs style.
127    pub fn with_mod_rs_style(mut self) -> Self {
128        self.use_mod_rs = true;
129        self
130    }
131
132    /// Set root file name (e.g., "main.rs" for binaries).
133    pub fn with_root_file(mut self, name: impl Into<String>) -> Self {
134        self.root_file = name.into();
135        self
136    }
137
138    /// Generate files from a ModuleTree.
139    pub fn generate(&self, tree: &ModuleTree) -> Result<GeneratedFiles, crate::pure::ToSynError> {
140        let mut files = GeneratedFiles::new();
141        let root_path = PathBuf::from(&self.root_file);
142
143        self.generate_module(tree, &root_path, &PathBuf::new(), true, &mut files)?;
144
145        Ok(files)
146    }
147
148    /// Generate a module and its children recursively.
149    ///
150    /// - `tree`: The module tree to generate
151    /// - `file_path`: Path to the file for this module
152    /// - `dir_path`: Directory path for child modules
153    /// - `is_root`: Whether this is the crate root
154    /// - `files`: Output map
155    fn generate_module(
156        &self,
157        tree: &ModuleTree,
158        file_path: &Path,
159        dir_path: &Path,
160        is_root: bool,
161        files: &mut GeneratedFiles,
162    ) -> Result<(), crate::pure::ToSynError> {
163        // Build items for this module
164        let mut items = Vec::new();
165
166        // Add uses first
167        for u in &tree.uses {
168            items.push(PureItem::Use(u.clone()));
169        }
170
171        // Add items
172        items.extend(tree.items.iter().cloned());
173
174        // Add mod declarations for children (without content - they're in separate files)
175        // Outer attrs on the `mod foo;` declaration (e.g., `#[cfg(test)] mod tests;`)
176        // are carried from `child.outer_attrs`. Inner attrs of the child belong
177        // inside the child file (handled by the recursive call below).
178        for child in &tree.children {
179            items.push(PureItem::Mod(PureMod {
180                attrs: child.outer_attrs.clone(),
181                vis: child.vis.clone(),
182                name: child.name.clone(),
183                items: vec![], // External module - no inline content
184                scope: Default::default(),
185            }));
186        }
187
188        // Create PureFile for this module
189        let pure_file = PureFile {
190            attrs: tree.inner_attrs.clone(),
191            items,
192        };
193
194        let source = pure_file.to_source()?;
195        files.files.insert(
196            file_path.to_path_buf(),
197            GeneratedSource { source, pure_file },
198        );
199
200        // Generate child modules recursively
201        for child in &tree.children {
202            let (child_file_path, child_dir_path) = if self.use_mod_rs {
203                // Legacy style: module/mod.rs
204                let child_dir = dir_path.join(&child.name);
205                let child_file = child_dir.join("mod.rs");
206                (child_file, child_dir)
207            } else {
208                // Modern style: module.rs + module/
209                if child.children.is_empty() && is_root {
210                    // Leaf module at root level: just module.rs
211                    let child_file = dir_path.join(format!("{}.rs", child.name));
212                    (child_file, dir_path.join(&child.name))
213                } else if child.children.is_empty() {
214                    // Leaf module in subdir: dir/module.rs
215                    let child_file = dir_path.join(format!("{}.rs", child.name));
216                    (child_file, dir_path.join(&child.name))
217                } else {
218                    // Non-leaf module: module.rs + module/
219                    let child_file = dir_path.join(format!("{}.rs", child.name));
220                    let child_dir = dir_path.join(&child.name);
221                    (child_file, child_dir)
222                }
223            };
224
225            self.generate_module(child, &child_file_path, &child_dir_path, false, files)?;
226        }
227        Ok(())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::pure::{
235        PureAttrMeta, PureAttribute, PureBlock, PureFields, PureFn, PureGenerics, PureStruct,
236        PureUse, PureUseTree, PureVis,
237    };
238
239    fn make_struct(name: &str) -> PureItem {
240        PureItem::Struct(PureStruct {
241            attrs: vec![],
242            vis: PureVis::Public,
243            name: name.to_string(),
244            generics: PureGenerics::default(),
245            fields: PureFields::Unit,
246        })
247    }
248
249    fn make_fn(name: &str) -> PureItem {
250        PureItem::Fn(PureFn {
251            attrs: vec![],
252            vis: PureVis::Public,
253            is_async: false,
254            is_async_inferred: false,
255            is_const: false,
256            is_unsafe: false,
257            abi: None,
258            name: name.to_string(),
259            generics: PureGenerics::default(),
260            params: vec![],
261            ret: None,
262            body: PureBlock::default(),
263        })
264    }
265
266    fn make_use(path: &str) -> PureUse {
267        let parts: Vec<&str> = path.split("::").collect();
268        let tree = build_use_tree(&parts);
269        PureUse {
270            vis: PureVis::Private,
271            tree,
272        }
273    }
274
275    fn build_use_tree(parts: &[&str]) -> PureUseTree {
276        if parts.len() == 1 {
277            PureUseTree::Name(parts[0].to_string())
278        } else {
279            PureUseTree::Path {
280                path: parts[0].to_string(),
281                tree: Box::new(build_use_tree(&parts[1..])),
282            }
283        }
284    }
285
286    // ========================================================================
287    // Basic Tests
288    // ========================================================================
289
290    #[test]
291    fn test_multi_file_single_module() {
292        let tree = ModuleTree::crate_root().with_item(make_struct("Config"));
293
294        let generator = MultiFileGenerator::new();
295        let result = generator.generate(&tree).unwrap();
296
297        assert_eq!(result.len(), 1);
298        assert!(result.get(Path::new("lib.rs")).is_some());
299
300        let lib = result.get(Path::new("lib.rs")).unwrap();
301        assert!(lib.source.contains("struct Config"));
302    }
303
304    #[test]
305    fn test_multi_file_with_child_modern_style() {
306        let tree = ModuleTree::crate_root()
307            .with_item(make_struct("Config"))
308            .with_child(
309                ModuleTree::new("models")
310                    .with_vis(PureVis::Public)
311                    .with_item(make_struct("User")),
312            );
313
314        let generator = MultiFileGenerator::new();
315        let result = generator.generate(&tree).unwrap();
316
317        assert_eq!(result.len(), 2);
318
319        // lib.rs should have mod declaration
320        let lib = result.get(Path::new("lib.rs")).unwrap();
321        assert!(lib.source.contains("struct Config"));
322        assert!(lib.source.contains("pub mod models;"));
323        assert!(!lib.source.contains("struct User")); // User is in separate file
324
325        // models.rs should have User
326        let models = result.get(Path::new("models.rs")).unwrap();
327        assert!(models.source.contains("struct User"));
328    }
329
330    #[test]
331    fn test_multi_file_with_child_mod_rs_style() {
332        let tree = ModuleTree::crate_root()
333            .with_item(make_struct("Config"))
334            .with_child(
335                ModuleTree::new("models")
336                    .with_vis(PureVis::Public)
337                    .with_item(make_struct("User")),
338            );
339
340        let generator = MultiFileGenerator::new().with_mod_rs_style();
341        let result = generator.generate(&tree).unwrap();
342
343        assert_eq!(result.len(), 2);
344
345        // lib.rs should have mod declaration
346        let lib = result.get(Path::new("lib.rs")).unwrap();
347        assert!(lib.source.contains("pub mod models;"));
348
349        // models/mod.rs should have User
350        let models = result.get(Path::new("models/mod.rs")).unwrap();
351        assert!(models.source.contains("struct User"));
352    }
353
354    #[test]
355    fn test_multi_file_nested_modules() {
356        let tree = ModuleTree::crate_root().with_child(
357            ModuleTree::new("models")
358                .with_vis(PureVis::Public)
359                .with_item(make_struct("User"))
360                .with_child(
361                    ModuleTree::new("dto")
362                        .with_vis(PureVis::Public)
363                        .with_item(make_struct("UserDto")),
364                ),
365        );
366
367        let generator = MultiFileGenerator::new();
368        let result = generator.generate(&tree).unwrap();
369
370        assert_eq!(result.len(), 3);
371
372        // Check file structure
373        assert!(result.get(Path::new("lib.rs")).is_some());
374        assert!(result.get(Path::new("models.rs")).is_some());
375        assert!(result.get(Path::new("models/dto.rs")).is_some());
376
377        // Check content
378        let lib = result.get(Path::new("lib.rs")).unwrap();
379        assert!(lib.source.contains("pub mod models;"));
380
381        let models = result.get(Path::new("models.rs")).unwrap();
382        assert!(models.source.contains("struct User"));
383        assert!(models.source.contains("pub mod dto;"));
384
385        let dto = result.get(Path::new("models/dto.rs")).unwrap();
386        assert!(dto.source.contains("struct UserDto"));
387    }
388
389    #[test]
390    fn test_multi_file_with_uses() {
391        let tree = ModuleTree::crate_root()
392            .with_use(make_use("std::io"))
393            .with_child(
394                ModuleTree::new("utils")
395                    .with_use(make_use("std::fmt"))
396                    .with_item(make_fn("helper")),
397            );
398
399        let generator = MultiFileGenerator::new();
400        let result = generator.generate(&tree).unwrap();
401
402        let lib = result.get(Path::new("lib.rs")).unwrap();
403        assert!(lib.source.contains("use std") && lib.source.contains("io"));
404
405        let utils = result.get(Path::new("utils.rs")).unwrap();
406        assert!(utils.source.contains("use std") && utils.source.contains("fmt"));
407    }
408
409    // ========================================================================
410    // Diff Tests
411    // ========================================================================
412
413    #[test]
414    fn test_diff_new_file() {
415        let tree = ModuleTree::crate_root().with_item(make_struct("Config"));
416
417        let generator = MultiFileGenerator::new();
418        let generated = generator.generate(&tree).unwrap();
419
420        // No existing files
421        let existing: HashMap<PathBuf, PureFile> = HashMap::new();
422        let diff = generated.diff(&existing);
423
424        // All files should be in diff (they're new)
425        assert_eq!(diff.len(), 1);
426    }
427
428    #[test]
429    fn test_diff_unchanged() {
430        let tree = ModuleTree::crate_root().with_item(make_struct("Config"));
431
432        let generator = MultiFileGenerator::new();
433        let generated = generator.generate(&tree).unwrap();
434
435        // Same PureFile as generated
436        let mut existing: HashMap<PathBuf, PureFile> = HashMap::new();
437        existing.insert(
438            PathBuf::from("lib.rs"),
439            generated
440                .get(Path::new("lib.rs"))
441                .unwrap()
442                .pure_file
443                .clone(),
444        );
445
446        let diff = generated.diff(&existing);
447
448        // No changes
449        assert_eq!(diff.len(), 0);
450    }
451
452    #[test]
453    fn test_diff_changed() {
454        let tree = ModuleTree::crate_root().with_item(make_struct("Config"));
455
456        let generator = MultiFileGenerator::new();
457        let generated = generator.generate(&tree).unwrap();
458
459        // Different PureFile
460        let mut existing: HashMap<PathBuf, PureFile> = HashMap::new();
461        existing.insert(
462            PathBuf::from("lib.rs"),
463            PureFile {
464                attrs: vec![],
465                items: vec![make_struct("OldConfig")], // Different!
466            },
467        );
468
469        let diff = generated.diff(&existing);
470
471        // lib.rs changed
472        assert_eq!(diff.len(), 1);
473        assert!(diff.get(Path::new("lib.rs")).is_some());
474    }
475
476    #[test]
477    fn test_deleted_paths() {
478        let tree = ModuleTree::crate_root().with_item(make_struct("Config"));
479
480        let generator = MultiFileGenerator::new();
481        let generated = generator.generate(&tree).unwrap();
482
483        // Existing has extra file
484        let mut existing: HashMap<PathBuf, PureFile> = HashMap::new();
485        existing.insert(PathBuf::from("lib.rs"), PureFile::default());
486        existing.insert(PathBuf::from("old_module.rs"), PureFile::default());
487
488        let deleted = generated.deleted_paths(&existing);
489
490        assert_eq!(deleted.len(), 1);
491        assert_eq!(deleted[0], &PathBuf::from("old_module.rs"));
492    }
493
494    // ========================================================================
495    // Main.rs Tests
496    // ========================================================================
497
498    #[test]
499    fn test_multi_file_main_rs() {
500        let tree = ModuleTree::crate_root().with_item(make_fn("main"));
501
502        let generator = MultiFileGenerator::new().with_root_file("main.rs");
503        let result = generator.generate(&tree).unwrap();
504
505        assert!(result.get(Path::new("main.rs")).is_some());
506        assert!(result.get(Path::new("lib.rs")).is_none());
507    }
508
509    // ========================================================================
510    // Deep Nesting Tests
511    // ========================================================================
512
513    #[test]
514    fn test_multi_file_deep_nesting() {
515        let tree = ModuleTree::crate_root().with_child(ModuleTree::new("a").with_child(
516            ModuleTree::new("b").with_child(ModuleTree::new("c").with_item(make_struct("Deep"))),
517        ));
518
519        let generator = MultiFileGenerator::new();
520        let result = generator.generate(&tree).unwrap();
521
522        // Should have: lib.rs, a.rs, a/b.rs, a/b/c.rs
523        assert_eq!(result.len(), 4);
524        assert!(result.get(Path::new("lib.rs")).is_some());
525        assert!(result.get(Path::new("a.rs")).is_some());
526        assert!(result.get(Path::new("a/b.rs")).is_some());
527        assert!(result.get(Path::new("a/b/c.rs")).is_some());
528
529        let c = result.get(Path::new("a/b/c.rs")).unwrap();
530        assert!(c.source.contains("struct Deep"));
531    }
532
533    #[test]
534    fn test_multi_file_deep_nesting_mod_rs() {
535        let tree = ModuleTree::crate_root().with_child(
536            ModuleTree::new("a").with_child(ModuleTree::new("b").with_item(make_struct("Deep"))),
537        );
538
539        let generator = MultiFileGenerator::new().with_mod_rs_style();
540        let result = generator.generate(&tree).unwrap();
541
542        // Should have: lib.rs, a/mod.rs, a/b/mod.rs
543        assert_eq!(result.len(), 3);
544        assert!(result.get(Path::new("lib.rs")).is_some());
545        assert!(result.get(Path::new("a/mod.rs")).is_some());
546        assert!(result.get(Path::new("a/b/mod.rs")).is_some());
547    }
548
549    // ========================================================================
550    // Valid Rust Tests
551    // ========================================================================
552
553    #[test]
554    fn test_all_generated_files_are_valid_rust() {
555        let tree = ModuleTree::crate_root()
556            .with_use(make_use("std::collections::HashMap"))
557            .with_item(make_struct("App"))
558            .with_child(
559                ModuleTree::new("models")
560                    .with_vis(PureVis::Public)
561                    .with_item(make_struct("User"))
562                    .with_child(
563                        ModuleTree::new("dto")
564                            .with_vis(PureVis::Public)
565                            .with_item(make_struct("UserDto")),
566                    ),
567            )
568            .with_child(ModuleTree::new("utils").with_item(make_fn("helper")));
569
570        let generator = MultiFileGenerator::new();
571        let result = generator.generate(&tree).unwrap();
572
573        for (path, generated) in &result.files {
574            syn::parse_str::<syn::File>(&generated.source).unwrap_or_else(|_| {
575                panic!(
576                    "File {} should be valid Rust:\n{}",
577                    path.display(),
578                    generated.source
579                )
580            });
581        }
582    }
583
584    // ========================================================================
585    // R9-pattern API gap regression: outer attrs on `mod foo;` declarations
586    // ========================================================================
587
588    #[test]
589    fn test_multi_file_carries_outer_attrs_on_child_mod_decl() {
590        // Boundary: ModuleTree::outer_attrs must reach the generated
591        // `mod foo;` declaration in the parent file. Pre-fix, multi.rs
592        // hardcoded `attrs: vec![]` for the external mod decl, silently
593        // dropping outer attrs like `#[cfg(test)] mod tests;`. This is
594        // the same class of attrs drop that R9 fixed at the registry
595        // layer for `store_items_from_file`.
596        let cfg_test = PureAttribute {
597            path: "cfg".to_string(),
598            meta: PureAttrMeta::List("test".to_string()),
599            is_inner: false,
600        };
601
602        let tree = ModuleTree::crate_root()
603            .with_item(make_struct("Config"))
604            .with_child(
605                ModuleTree::new("tests")
606                    .with_vis(PureVis::Private)
607                    .with_outer_attr(cfg_test)
608                    .with_item(make_fn("smoke")),
609            );
610
611        let generator = MultiFileGenerator::new();
612        let result = generator.generate(&tree).unwrap();
613
614        let lib = result.get(Path::new("lib.rs")).unwrap();
615        assert!(
616            lib.source.contains("#[cfg(test)]"),
617            "lib.rs must carry outer attr on `mod tests;` decl, got:\n{}",
618            lib.source
619        );
620        assert!(lib.source.contains("mod tests"));
621    }
622}