Skip to main content

ryo_executor/engine/
registry_generator.rs

1//! RegistryGenerator - Generate source files from ASTRegistry + SymbolRegistry
2//!
3//! This generator works purely from SymbolPath, without requiring existing files
4//! or span information. File paths are derived from SymbolPath hierarchy.
5//!
6//! # Responsibility Boundary
7//!
8//! `RegistryGenerator` is responsible **only** for:
9//! - Deriving module structure from SymbolPath hierarchy
10//! - Generating valid Rust source code for each module
11//! - Outputting **crate-relative paths** (e.g., `"src/lib.rs"`, `"src/models.rs"`)
12//!
13//! It is **NOT** responsible for:
14//! - Knowing workspace layout (e.g., `"crates/core/src/lib.rs"`)
15//! - Converting to `WorkspaceFilePath`
16//! - Writing files to disk
17//!
18//! The caller (typically `BlueprintExecutor::sync_files_and_rebuild`) handles
19//! the conversion from crate-relative paths to workspace-relative paths by
20//! consulting existing file metadata.
21//!
22//! # Design
23//!
24//! ```text
25//! ASTRegistry + SymbolRegistry
26//!         ↓
27//!   Group by SymbolPath.crate_name()
28//!         ↓
29//!   Build module hierarchy from SymbolPath
30//!         ↓
31//!   Generate PureFile per module
32//!         ↓
33//!   Output: GeneratedWorkspace
34//!     - crate_name: "core"
35//!     - files: {"src/lib.rs" → source, "src/models.rs" → source, ...}
36//! ```
37//!
38//! # Example
39//!
40//! ```ignore
41//! let generator = RegistryGenerator::multi_file();
42//! let workspace = generator.generate(&ast_registry, &symbol_registry);
43//!
44//! // workspace.crates["core"].files contains:
45//! // - "src/lib.rs" → "pub struct Config { ... }"
46//! // - "src/models.rs" → "pub struct User { ... }"
47//!
48//! // The caller then converts these to WorkspaceFilePath:
49//! // - "crates/core/src/lib.rs"
50//! // - "crates/core/src/models.rs"
51//! ```
52
53use ryo_analysis::{ASTRegistry, CodeGraphV2, SymbolId, SymbolKind};
54use ryo_source::pure::{PureAttribute, PureFile, PureItem, PureMod, PureVis, ToSynError};
55use ryo_symbol::{SymbolPath, SymbolRegistry};
56use std::collections::{BTreeMap, HashMap, HashSet};
57
58/// Key type for module symbol attributes map: `(crate_name, mod_path_segments, is_public)`
59type ModSymbolKey = (String, Vec<String>, bool);
60
61/// Value type for module symbol attributes map: `(is_inline, attributes)`
62type ModSymbolValue = (bool, Vec<PureAttribute>);
63
64/// Key for grouping symbols by crate: `(crate_name, is_main)`
65type CrateKey = (String, bool);
66
67/// Symbol entry for generation: `(symbol_id, symbol_path, pure_item)`
68type SymbolEntry = (SymbolId, SymbolPath, PureItem);
69
70/// Options for multi-file generation.
71struct MultiFileOpts<'a> {
72    /// If true, root file is `src/main.rs`, otherwise `src/lib.rs`
73    is_main: bool,
74    /// Module-level use items keyed by `(crate, segments, is_main)`
75    module_use_items: &'a HashMap<ModSymbolKey, Vec<PureItem>>,
76    /// Module symbol entries (empty modules)
77    mod_symbols: &'a HashMap<ModSymbolKey, ModSymbolValue>,
78    /// Fully-qualified paths of inline modules
79    inline_module_paths: &'a HashSet<String>,
80    /// When `Some`, only these file keys are serialized to output
81    affected_file_keys: Option<&'a HashSet<String>>,
82}
83
84/// Result of generation for a single crate.
85///
86/// Contains generated source files keyed by **crate-relative paths**
87/// (e.g., `"src/lib.rs"`, `"src/models.rs"`), not workspace-relative paths.
88#[derive(Debug, Clone)]
89pub struct GeneratedCrate {
90    /// Crate name (e.g., `"core"`, `"app"`)
91    pub crate_name: String,
92
93    /// Generated files: crate-relative path → generated file
94    ///
95    /// Keys are paths relative to the crate root, such as:
96    /// - `"src/lib.rs"` (library entry point)
97    /// - `"src/main.rs"` (binary entry point)
98    /// - `"src/models.rs"` (submodule)
99    /// - `"src/models/user.rs"` (nested submodule)
100    ///
101    /// **Note**: These are NOT workspace-relative paths. The caller must
102    /// prepend the crate's root directory (e.g., `"crates/core"`) to get
103    /// the full workspace path.
104    pub files: HashMap<String, GeneratedFile>,
105}
106
107/// A single generated file.
108#[derive(Debug, Clone)]
109pub struct GeneratedFile {
110    /// The generated source code as a string
111    pub source: String,
112    /// The PureFile AST representation (useful for diff comparison)
113    pub pure_file: PureFile,
114}
115
116/// Result of multi-crate workspace generation.
117///
118/// Contains all generated crates, keyed by crate name.
119///
120/// # Path Convention
121///
122/// All file paths within this structure are **crate-relative**, not workspace-relative.
123/// For example, a workspace with structure:
124///
125/// ```text
126/// workspace/
127/// ├── Cargo.toml
128/// └── crates/
129///     ├── core/
130///     │   └── src/lib.rs
131///     └── app/
132///         └── src/main.rs
133/// ```
134///
135/// Would produce:
136/// - `crates["core"].files["src/lib.rs"]`
137/// - `crates["app"].files["src/main.rs"]`
138///
139/// The caller is responsible for combining these with crate root paths
140/// to produce workspace-relative paths like `"crates/core/src/lib.rs"`.
141#[derive(Debug, Clone, Default)]
142pub struct GeneratedWorkspace {
143    /// Map of crate_name → GeneratedCrate
144    pub crates: HashMap<String, GeneratedCrate>,
145}
146
147impl GeneratedWorkspace {
148    /// Get total file count across all crates.
149    pub fn total_files(&self) -> usize {
150        self.crates.values().map(|c| c.files.len()).sum()
151    }
152
153    /// Iterate all files with full paths.
154    pub fn iter_files(&self) -> impl Iterator<Item = (&str, &str, &GeneratedFile)> {
155        self.crates.iter().flat_map(|(crate_name, crate_data)| {
156            crate_data
157                .files
158                .iter()
159                .map(move |(path, file)| (crate_name.as_str(), path.as_str(), file))
160        })
161    }
162}
163
164/// Generator that creates source files from ASTRegistry + SymbolRegistry.
165///
166/// Derives file structure purely from SymbolPath hierarchy - no existing files
167/// or span information required. This enables:
168/// - Fresh file generation from scratch
169/// - Module addition/removal without span manipulation
170/// - Consistent output regardless of original file layout
171///
172/// # Output Format
173///
174/// The generator outputs `GeneratedWorkspace` containing **crate-relative paths**
175/// (e.g., `"src/lib.rs"`). It does NOT handle workspace layout concerns like
176/// where each crate lives within the workspace (e.g., `"crates/core/src/lib.rs"`).
177///
178/// # Generation Modes
179///
180/// - **Single-file mode** (`single_file()`): All symbols in one file with nested `mod { }` blocks
181/// - **Multi-file mode** (`multi_file()`): Each module gets its own `.rs` file
182/// - **Multi-file mod.rs** (`multi_file_mod_rs()`): Legacy `mod.rs` style instead of `module.rs`
183///
184/// # Example
185///
186/// ```ignore
187/// let generator = RegistryGenerator::multi_file();
188/// let workspace = generator.generate(&ast_registry, &symbol_registry);
189///
190/// // For symbols: core::Config, core::models::User
191/// // Output: {"src/lib.rs": "...", "src/models.rs": "..."}
192/// ```
193#[derive(Debug, Clone, Default)]
194pub struct RegistryGenerator {
195    /// Generate single file with nested mods (true) or multi-file (false)
196    pub single_file: bool,
197    /// Use mod.rs style (legacy) vs module.rs style (modern) for multi-file
198    pub use_mod_rs: bool,
199}
200
201impl RegistryGenerator {
202    /// Create a new generator with single-file output.
203    pub fn single_file() -> Self {
204        Self {
205            single_file: true,
206            use_mod_rs: false,
207        }
208    }
209
210    /// Create a new generator with multi-file output (modern style).
211    pub fn multi_file() -> Self {
212        Self {
213            single_file: false,
214            use_mod_rs: false,
215        }
216    }
217
218    /// Create a new generator with multi-file output (mod.rs style).
219    pub fn multi_file_mod_rs() -> Self {
220        Self {
221            single_file: false,
222            use_mod_rs: true,
223        }
224    }
225
226    /// Generate source files from registries.
227    ///
228    /// Handles both library symbols (`crate::Foo`) and binary symbols (`main::crate::Foo`).
229    /// Binary symbols are output to `src/main.rs` while library symbols go to `src/lib.rs`.
230    pub fn generate(
231        &self,
232        ast_registry: &ASTRegistry,
233        symbol_registry: &SymbolRegistry,
234    ) -> Result<GeneratedWorkspace, ToSynError> {
235        self.generate_internal(ast_registry, symbol_registry, None)
236    }
237
238    /// Internal generation with optional file-level filter.
239    ///
240    /// When `affected_file_keys` is `Some`, only files whose crate-relative path
241    /// (e.g. "src/models.rs") is in the set will be serialized via `to_source()`.
242    /// This avoids the expensive prettyplease formatting for unmodified files.
243    ///
244    /// When `None`, all files are generated (full generation mode).
245    fn generate_internal(
246        &self,
247        ast_registry: &ASTRegistry,
248        symbol_registry: &SymbolRegistry,
249        affected_file_keys: Option<&HashSet<String>>,
250    ) -> Result<GeneratedWorkspace, ToSynError> {
251        // First pass: identify all inline module paths
252        // Inline modules are marked during initial parsing in ASTRegistry.
253        // Items inside inline modules should NOT be added separately to the symbol list
254        // because they are already contained in the PureMod.items field.
255        let mut inline_module_paths: HashSet<String> = HashSet::new();
256        for id in ast_registry.inline_module_ids() {
257            if let Some(path) = symbol_registry.path(id) {
258                inline_module_paths.insert(path.to_string());
259            }
260        }
261
262        // Group symbols by crate, separating lib and main symbols
263        // Key: (crate_name, is_main)
264        let mut crates: HashMap<CrateKey, Vec<SymbolEntry>> = HashMap::new();
265
266        for (id, item) in ast_registry.iter() {
267            // Skip impl blocks, methods, and fields - they're associated items
268            // handled separately from module_items and should not create module tree entries.
269            // Fields can leak here due to name collisions between struct fields and impl methods
270            // (e.g., `Type::field_name` path shared by both), where re-registration changes
271            // the kind from Method to Field while the PureItem remains Fn.
272            let kind = symbol_registry.kind(id);
273            if matches!(
274                kind,
275                Some(SymbolKind::Impl | SymbolKind::Method | SymbolKind::Field)
276            ) {
277                continue;
278            }
279
280            if let Some(path) = symbol_registry.path(id) {
281                // Skip associated items inside impl blocks (e.g., type Context, type Error)
282                // These have paths like "module::<impl Trait for Type>::ItemName"
283                if path.segments().any(|s| s.starts_with("<impl ")) {
284                    continue;
285                }
286
287                // Skip items that are children of inline modules.
288                // Inline modules contain their items in PureMod.items, so we don't need
289                // to add child items separately - they would create duplicate entries.
290                let path_str = path.to_string();
291                let is_child_of_inline = inline_module_paths.iter().any(|inline_path| {
292                    // Check if this item's path is a child of an inline module
293                    // Must check for :: separator to avoid false positives
294                    // e.g., "crate::testing" should NOT match "crate::test"
295                    let prefix_with_sep = format!("{}::", inline_path);
296                    path_str.starts_with(&prefix_with_sep)
297                });
298                if is_child_of_inline {
299                    continue;
300                }
301
302                let (crate_name, is_main) = if path.is_main_symbol() {
303                    // main::my_crate::Foo -> crate_name = "my_crate", is_main = true
304                    (
305                        path.main_target_crate()
306                            .unwrap_or(path.crate_name())
307                            .to_string(),
308                        true,
309                    )
310                } else {
311                    // my_crate::Foo -> crate_name = "my_crate", is_main = false
312                    (path.crate_name().to_string(), false)
313                };
314
315                crates.entry((crate_name, is_main)).or_default().push((
316                    id,
317                    path.clone(),
318                    item.clone(),
319                ));
320            }
321        }
322
323        // Collect module_items (use statements, etc.) by module path
324        // Key: (crate_name, module_path_segments, is_main)
325        let mut module_use_items: HashMap<ModSymbolKey, Vec<PureItem>> = HashMap::new();
326
327        for (module_id, items) in ast_registry.iter_module_items() {
328            tracing::debug!(
329                "iter_module_items: module_id={:?}, items_count={}, has_use={}",
330                module_id,
331                items.len(),
332                items.iter().any(|i| matches!(i, PureItem::Use(_)))
333            );
334            if let Some(path) = symbol_registry.path(module_id) {
335                let (crate_name, is_main) = if path.is_main_symbol() {
336                    (
337                        path.main_target_crate()
338                            .unwrap_or(path.crate_name())
339                            .to_string(),
340                        true,
341                    )
342                } else {
343                    (path.crate_name().to_string(), false)
344                };
345
346                // Get module segments (path minus crate prefix)
347                // Use mod_path() which correctly handles both lib and main symbols
348                // Filter out impl block segments (e.g., "<impl Trait for Type>") which
349                // should not create module hierarchy entries
350                let segments: Vec<String> = path
351                    .mod_path()
352                    .iter()
353                    .map(|s| s.name())
354                    .filter(|name| !name.starts_with("<impl "))
355                    .map(|s| s.to_string())
356                    .collect();
357
358                // Filter Use items and Impl blocks
359                // New design: impl blocks are stored in module_items (file-level construct)
360                // Other items in module_items are already in symbols and should be filtered
361                let file_level_items: Vec<PureItem> = items
362                    .iter()
363                    .filter(|item| matches!(item, PureItem::Use(_) | PureItem::Impl(_)))
364                    .cloned()
365                    .collect();
366
367                if !file_level_items.is_empty() {
368                    tracing::debug!(
369                        "Adding {} file_level_items to module_use_items for path={}, segments={:?}",
370                        file_level_items.len(),
371                        path,
372                        segments
373                    );
374                    module_use_items
375                        .entry((crate_name, segments, is_main))
376                        .or_default()
377                        .extend(file_level_items);
378                }
379            }
380        }
381
382        // Ensure crates exists for all modules with module_items (use statements)
383        // This handles cases where a file has only use statements and no other symbols
384        for (crate_name, segments, is_main) in module_use_items.keys() {
385            if segments.is_empty() {
386                // Root module (e.g., "crate") - ensure crate entry exists
387                crates.entry((crate_name.clone(), *is_main)).or_default();
388            }
389        }
390
391        // Collect Mod symbols from SymbolRegistry (for empty modules without child items)
392        // Key: (crate_name, module_segments, is_main), Value: (is_pub, attrs)
393        let mut mod_symbols: HashMap<ModSymbolKey, ModSymbolValue> = HashMap::new();
394        for (id, path) in symbol_registry.iter() {
395            let Some(kind) = symbol_registry.kind(id) else {
396                continue;
397            };
398            if kind != SymbolKind::Mod {
399                continue;
400            }
401            // Skip inline modules (marked via mark_inline_module)
402            // These are already included in their parent file and will be output as-is
403            // External modules (not marked) need visibility preserved even if items is non-empty
404            if ast_registry.is_inline_module(id) {
405                continue;
406            }
407            let mod_attrs = if let Some(PureItem::Mod(m)) = ast_registry.get(id) {
408                m.attrs.clone()
409            } else {
410                Vec::new()
411            };
412
413            let segments: Vec<&str> = path.segments().collect();
414            let (crate_name, is_main) = if path.is_main_symbol() {
415                (
416                    path.main_target_crate()
417                        .unwrap_or(path.crate_name())
418                        .to_string(),
419                    true,
420                )
421            } else {
422                (path.crate_name().to_string(), false)
423            };
424
425            // Handle crate root modules (e.g., "crate" or "main::crate")
426            // Store with empty segments to set attrs on root tree node
427            if segments.len() <= 1 || (path.is_main_symbol() && segments.len() <= 2) {
428                // Crate root - store with empty segments for root node attrs
429                mod_symbols.insert(
430                    (crate_name.clone(), vec![], is_main),
431                    (true, mod_attrs), // Crate root is always "pub" in a sense
432                );
433                crates.entry((crate_name, is_main)).or_default();
434                continue;
435            }
436
437            // Module segments (path minus crate prefix)
438            // Filter out impl block segments (shouldn't happen for Mod symbols, but defensive)
439            let skip_count = if is_main { 2 } else { 1 };
440            let module_segments: Vec<String> = segments[skip_count..]
441                .iter()
442                .filter(|s| !s.starts_with("<impl "))
443                .map(|s| s.to_string())
444                .collect();
445
446            // Determine visibility from SymbolRegistry
447            let is_pub = symbol_registry
448                .visibility(id)
449                .map(|v| matches!(v, ryo_symbol::Visibility::Public))
450                .unwrap_or(false);
451
452            mod_symbols.insert(
453                (crate_name.clone(), module_segments, is_main),
454                (is_pub, mod_attrs),
455            );
456
457            // Ensure crate entry exists
458            crates.entry((crate_name, is_main)).or_default();
459        }
460
461        // Generate each crate (merge lib and main into same GeneratedCrate)
462        let mut workspace = GeneratedWorkspace::default();
463
464        for ((crate_name, is_main), symbols) in crates {
465            let entry = workspace
466                .crates
467                .entry(crate_name.clone())
468                .or_insert_with(|| GeneratedCrate {
469                    crate_name: crate_name.clone(),
470                    files: HashMap::new(),
471                });
472
473            let generated_files = if self.single_file {
474                self.generate_single_file_inner(
475                    &crate_name,
476                    symbols,
477                    is_main,
478                    &module_use_items,
479                    &mod_symbols,
480                )
481            } else {
482                self.generate_multi_file_inner(
483                    &crate_name,
484                    symbols,
485                    MultiFileOpts {
486                        is_main,
487                        module_use_items: &module_use_items,
488                        mod_symbols: &mod_symbols,
489                        inline_module_paths: &inline_module_paths,
490                        affected_file_keys,
491                    },
492                )
493            };
494
495            entry.files.extend(generated_files?);
496        }
497
498        Ok(workspace)
499    }
500
501    /// Generate only files affected by modified symbols.
502    ///
503    /// This method determines which files need regeneration based on the modified symbols
504    /// and the generator's file layout strategy (CrateLayout-aware).
505    ///
506    /// # Strategy
507    ///
508    /// 1. Collect affected modules from modified symbols
509    /// 2. Determine file paths based on CrateLayout (bin-only/lib/mixed)
510    /// 3. Generate only the affected files
511    ///
512    /// # Arguments
513    ///
514    /// * `ast_registry` - The AST registry containing all symbols
515    /// * `symbol_registry` - The symbol registry with path information
516    /// * `modified_symbols` - SymbolIds that were modified during mutation
517    /// * `metadata` - Cargo metadata for CrateInfo lookup
518    ///
519    /// # Returns
520    ///
521    /// A `GeneratedWorkspace` containing only the affected files with crate-relative paths.
522    ///
523    /// # Example
524    ///
525    /// ```ignore
526    /// let modified = vec![status_id, todo_item_id];
527    /// let workspace = generator.generate_affected(
528    ///     &ast_registry,
529    ///     &symbol_registry,
530    ///     &modified,
531    ///     &metadata,
532    /// );
533    /// // workspace.crates["my_crate"].files contains only affected files
534    /// ```
535    /// Generate only files affected by modified symbols.
536    ///
537    /// Maps each modified SymbolId to its crate-relative file path, then runs
538    /// the full organization phase (fast: HashMap grouping) but only serializes
539    /// affected files via `to_source()` (expensive: prettyplease).
540    ///
541    /// # DESIGN INVARIANT
542    ///
543    /// **Full-workspace serialization is PROHIBITED.** Only files containing
544    /// modified symbols may be serialized. This is critical because:
545    ///
546    /// 1. `to_source()` (prettyplease) is O(file_size) per file
547    /// 2. Regenerating unmodified files causes format drift
548    ///    (`vec![]` → `vec!()`, shorthand expansion, etc.)
549    /// 3. Writing unmodified files wastes I/O and triggers re-indexing
550    ///
551    /// If `modified_symbols` is empty, returns an empty workspace immediately.
552    pub fn generate_affected(
553        &self,
554        ast_registry: &ASTRegistry,
555        symbol_registry: &SymbolRegistry,
556        modified_symbols: &[SymbolId],
557        _metadata: &ryo_symbol::CargoMetadataProvider,
558    ) -> Result<GeneratedWorkspace, ToSynError> {
559        if modified_symbols.is_empty() {
560            return Ok(GeneratedWorkspace::default());
561        }
562
563        // Step 1: Map modified SymbolIds → crate-relative file paths.
564        //
565        // SymbolPath derivation rules (multi-file mode):
566        //   crate::Item              → src/lib.rs
567        //   crate::module::Item      → src/module.rs
568        //   crate::module::sub::Item → src/module/sub.rs
569        //   main::crate::Item        → src/main.rs
570        //   main::crate::cli::Args   → src/cli.rs
571        //
572        // IMPORTANT: Method/Impl symbols have paths like crate::Type::method
573        // where "Type" is a struct/enum, NOT a module. We must resolve these
574        // to their file-level ancestor (direct child of a Mod) before computing
575        // the file key, otherwise "Type" would be misinterpreted as a module name.
576        let mut affected_file_keys: HashSet<String> = modified_symbols
577            .iter()
578            .filter_map(|&id| {
579                let path = symbol_registry.path(id)?;
580                let resolved =
581                    Self::resolve_to_file_level_symbol(path, symbol_registry, ast_registry);
582                Some(self.symbol_path_to_file_key(&resolved))
583            })
584            .collect();
585
586        // Step 1b: For modified Mod symbols (non-inline), also include the module's
587        // OWN file. symbol_path_to_file_key maps a module to its PARENT file (where
588        // `mod foo;` is declared), but we also need the module's content file
589        // (e.g., `src/foo.rs`) to be generated — especially for newly created modules
590        // that have no prior file on disk.
591        for &id in modified_symbols {
592            if symbol_registry.kind(id) != Some(SymbolKind::Mod) {
593                continue;
594            }
595            if ast_registry.is_inline_module(id) {
596                continue;
597            }
598            if let Some(path) = symbol_registry.path(id) {
599                // The module's own file key = parent_file_key + derive_child_path(mod_name)
600                let parent_key = self.symbol_path_to_file_key(path);
601                if let Some(mod_name) = path.segments().last() {
602                    let mod_file_key = self.derive_child_path(&parent_key, mod_name);
603                    affected_file_keys.insert(mod_file_key);
604                }
605            }
606        }
607
608        tracing::debug!(
609            "generate_affected: {} modified symbols → {} affected file keys: {:?}",
610            modified_symbols.len(),
611            affected_file_keys.len(),
612            affected_file_keys,
613        );
614
615        // Step 2: Run organization + selective serialization.
616        // The organization phase (symbol grouping, module tree building) runs for ALL symbols
617        // but is fast (HashMap operations only). The expensive to_source() serialization
618        // is skipped for files not in affected_file_keys.
619        self.generate_internal(ast_registry, symbol_registry, Some(&affected_file_keys))
620    }
621
622    /// Resolve a symbol path to its file-level ancestor.
623    ///
624    /// Methods and associated items have paths like `crate::Type::method` where
625    /// "Type" is a struct/enum, not a module. `symbol_path_to_file_key` would
626    /// incorrectly treat "Type" as a module segment and map to `src/Type.rs`.
627    ///
628    /// This function walks up the path until it finds a symbol whose parent is a
629    /// non-inline Mod (or the crate root). Inline modules (defined with `mod name { ... }`)
630    /// don't have separate files, so symbols inside them must be mapped to the
631    /// file of the enclosing non-inline module.
632    ///
633    /// # Examples
634    /// - `crate::Config::new` (Method) → `crate::Config` (Struct, parent=Mod)
635    /// - `crate::foo::Bar::new` (Method) → `crate::foo::Bar` (Struct, parent=Mod)
636    /// - `crate::Config` (Struct) → `crate::Config` (already file-level)
637    /// - `crate::tests::test_foo` (inside inline mod) → `crate::tests::test_foo`
638    ///   but parent `crate::tests` is inline → file key maps to `src/lib.rs`
639    fn resolve_to_file_level_symbol(
640        path: &SymbolPath,
641        registry: &SymbolRegistry,
642        ast_registry: &ASTRegistry,
643    ) -> SymbolPath {
644        let mut current = path.clone();
645        loop {
646            if let Some(parent) = current.parent() {
647                if let Some(parent_id) = registry.lookup(&parent) {
648                    if registry.kind(parent_id) == Some(SymbolKind::Mod) {
649                        // If this module is inline, keep walking up — inline modules
650                        // don't have separate files.
651                        if ast_registry.is_inline_module(parent_id) {
652                            current = parent;
653                            continue;
654                        }
655                        return current;
656                    }
657                }
658                current = parent;
659            } else {
660                return current;
661            }
662        }
663    }
664
665    /// Map a SymbolPath to its crate-relative file path.
666    ///
667    /// This replicates the file derivation logic used by `build_module_tree` +
668    /// `generate_files_from_tree` to determine which file a symbol belongs to.
669    ///
670    /// # Examples
671    /// - `my_crate::Config`           → `"src/lib.rs"`
672    /// - `my_crate::models::User`     → `"src/models.rs"`
673    /// - `my_crate::models::sub::Foo` → `"src/models/sub.rs"`
674    /// - `main::my_app::cli::Args`    → `"src/cli.rs"`
675    fn symbol_path_to_file_key(&self, path: &SymbolPath) -> String {
676        let segments: Vec<&str> = path.segments().collect();
677        let is_main = path.is_main_symbol();
678        let skip_count = if is_main { 2 } else { 1 };
679
680        // Module segments = everything between crate prefix and item name.
681        // For `crate::models::User`: skip_count=1, segments=["crate","models","User"]
682        //   → module_segments = ["models"]
683        // For `crate::Config`:       skip_count=1, segments=["crate","Config"]
684        //   → module_segments = []
685        // For a module itself `crate::models`: segments=["crate","models"]
686        //   → module_segments = [] (the module lives in the parent file for mod declaration,
687        //     but its own content is in src/models.rs)
688        //
689        // Edge case: when a Mod symbol itself is modified, we want BOTH the parent file
690        // (for `mod foo;` declaration) AND the module's own file. However,
691        // `collect_modified_symbols` already handles this by including the parent module
692        // for SymbolAdded/SymbolRemoved events.
693        let item_segments = if segments.len() > skip_count {
694            &segments[skip_count..segments.len() - 1]
695        } else {
696            &[]
697        };
698
699        // Filter out impl block segments
700        let module_segments: Vec<&str> = item_segments
701            .iter()
702            .filter(|s| !s.starts_with("<impl "))
703            .copied()
704            .collect();
705
706        let root_file = if is_main { "src/main.rs" } else { "src/lib.rs" };
707
708        if module_segments.is_empty() {
709            root_file.to_string()
710        } else {
711            // Walk the derive_child_path chain to get the correct file path
712            let mut current = root_file.to_string();
713            for seg in &module_segments {
714                current = self.derive_child_path(&current, seg);
715            }
716            current
717        }
718    }
719
720    /// Generate source files using CodeGraphV2 for module traversal.
721    ///
722    /// This version uses the Contains edges in CodeGraphV2 to traverse the module
723    /// hierarchy instead of re-grouping symbols with HashMaps. This provides O(1)
724    /// child lookup per symbol instead of O(N) HashMap grouping.
725    ///
726    /// # Arguments
727    /// * `code_graph` - The code graph with Contains edges representing module hierarchy
728    /// * `ast_registry` - The AST registry containing symbol ASTs
729    /// * `symbol_registry` - The symbol registry with path information
730    pub fn generate_with_graph(
731        &self,
732        code_graph: &CodeGraphV2,
733        ast_registry: &ASTRegistry,
734        symbol_registry: &SymbolRegistry,
735    ) -> Result<GeneratedWorkspace, ToSynError> {
736        let mut workspace = GeneratedWorkspace::default();
737
738        // Get crate roots from CodeGraphV2 and find their direct children
739        // Group children by crate_name and is_main
740        let mut crate_children: HashMap<(String, bool), Vec<SymbolId>> = HashMap::new();
741
742        for &root_id in code_graph.crate_roots() {
743            // Get path to determine crate_name and is_main
744            if let Some(path) = symbol_registry.path(root_id) {
745                let (crate_name, is_main) = if path.is_main_symbol() {
746                    (
747                        path.main_target_crate()
748                            .unwrap_or(path.crate_name())
749                            .to_string(),
750                        true,
751                    )
752                } else {
753                    (path.crate_name().to_string(), false)
754                };
755
756                // Get direct children of this crate root
757                let children: Vec<SymbolId> = code_graph.children_of(root_id).collect();
758                crate_children
759                    .entry((crate_name, is_main))
760                    .or_default()
761                    .extend(children);
762            }
763        }
764
765        // Generate each crate
766        for ((crate_name, is_main), root_ids) in crate_children {
767            let entry = workspace
768                .crates
769                .entry(crate_name.clone())
770                .or_insert_with(|| GeneratedCrate {
771                    crate_name: crate_name.clone(),
772                    files: HashMap::new(),
773                });
774
775            let generated_files = if self.single_file {
776                self.generate_single_file_from_graph(
777                    &crate_name,
778                    &root_ids,
779                    is_main,
780                    code_graph,
781                    ast_registry,
782                    symbol_registry,
783                )
784            } else {
785                self.generate_multi_file_from_graph(
786                    &crate_name,
787                    &root_ids,
788                    is_main,
789                    code_graph,
790                    ast_registry,
791                    symbol_registry,
792                )
793            };
794
795            entry.files.extend(generated_files?);
796        }
797
798        Ok(workspace)
799    }
800
801    /// Generate single file using CodeGraphV2 traversal.
802    fn generate_single_file_from_graph(
803        &self,
804        _crate_name: &str,
805        root_ids: &[SymbolId],
806        is_main: bool,
807        code_graph: &CodeGraphV2,
808        ast_registry: &ASTRegistry,
809        symbol_registry: &SymbolRegistry,
810    ) -> Result<HashMap<String, GeneratedFile>, ToSynError> {
811        let items =
812            self.collect_items_from_graph(root_ids, code_graph, ast_registry, symbol_registry);
813
814        // Extract inner attributes (#![...]) from crate root module for file-level attrs
815        let file_attrs = self.extract_file_attrs_from_ids(root_ids, ast_registry, symbol_registry);
816
817        let pure_file = PureFile {
818            attrs: file_attrs,
819            items,
820        };
821        let source = pure_file.to_source()?;
822
823        let root_file = if is_main { "src/main.rs" } else { "src/lib.rs" };
824
825        let mut files = HashMap::new();
826        files.insert(root_file.to_string(), GeneratedFile { source, pure_file });
827        Ok(files)
828    }
829
830    /// Generate multi-file using CodeGraphV2 traversal.
831    fn generate_multi_file_from_graph(
832        &self,
833        _crate_name: &str,
834        root_ids: &[SymbolId],
835        is_main: bool,
836        code_graph: &CodeGraphV2,
837        ast_registry: &ASTRegistry,
838        symbol_registry: &SymbolRegistry,
839    ) -> Result<HashMap<String, GeneratedFile>, ToSynError> {
840        let root_file = if is_main { "src/main.rs" } else { "src/lib.rs" };
841
842        let mut files = HashMap::new();
843        self.generate_file_from_graph(
844            root_file,
845            root_ids,
846            code_graph,
847            ast_registry,
848            symbol_registry,
849            &mut files,
850        )?;
851        Ok(files)
852    }
853
854    /// Recursively collect items from graph for single-file mode.
855    fn collect_items_from_graph(
856        &self,
857        ids: &[SymbolId],
858        code_graph: &CodeGraphV2,
859        ast_registry: &ASTRegistry,
860        symbol_registry: &SymbolRegistry,
861    ) -> Vec<PureItem> {
862        let mut items = Vec::new();
863
864        // Separate Mod symbols from other items
865        let mut mods: Vec<SymbolId> = Vec::new();
866        let mut non_mods: Vec<SymbolId> = Vec::new();
867
868        for &id in ids {
869            if let Some(kind) = symbol_registry.kind(id) {
870                if kind == SymbolKind::Mod {
871                    // Check if this Mod is actually a crate root (not a real submodule)
872                    // Use SymbolPath::is_crate_root() which uses precomputed crate_root_depth
873                    if let Some(path) = symbol_registry.path(id) {
874                        if path.is_crate_root() {
875                            // Crate root Mod: treat as non-mod (inline its contents)
876                            // Examples: main::my_app, my_lib
877                            non_mods.push(id);
878                        } else {
879                            // Real submodule: separate file
880                            // Examples: main::my_app::utils, my_lib::utils
881                            mods.push(id);
882                        }
883                    } else {
884                        mods.push(id);
885                    }
886                } else {
887                    non_mods.push(id);
888                }
889            }
890        }
891
892        // Add non-mod items first (excluding impl blocks)
893        // Impl blocks are added from module_items below
894        for id in non_mods {
895            // Skip impl blocks - they're handled separately from module_items
896            if symbol_registry.kind(id) == Some(SymbolKind::Impl) {
897                continue;
898            }
899            if let Some(item) = ast_registry.get(id) {
900                items.push(item.clone());
901            }
902        }
903
904        // Add use statements and impl blocks from module_items
905        // New design: impl blocks are stored in module_items (file-level construct)
906        // Use statements also stored in module_items for positioning control
907        for &id in ids {
908            if let Some(module_items) = ast_registry.get_module_items(id) {
909                for item in module_items {
910                    if matches!(item, PureItem::Use(_) | PureItem::Impl(_)) {
911                        items.push(item.clone());
912                    }
913                }
914            }
915        }
916
917        // Add nested modules with their content
918        for mod_id in mods {
919            let children: Vec<SymbolId> = code_graph.children_of(mod_id).collect();
920            let child_items =
921                self.collect_items_from_graph(&children, code_graph, ast_registry, symbol_registry);
922
923            // Get module name from path
924            if let Some(path) = symbol_registry.path(mod_id) {
925                let mod_name = path.segments().last().unwrap_or("unknown").to_string();
926
927                // Determine visibility
928                let vis = if child_items.iter().any(is_public) {
929                    PureVis::Public
930                } else {
931                    PureVis::Private
932                };
933
934                // Add use statements from this module to child_items
935                let mut all_items = Vec::new();
936                if let Some(module_items) = ast_registry.get_module_items(mod_id) {
937                    for item in module_items {
938                        if let PureItem::Use(_) = item {
939                            all_items.push(item.clone());
940                        }
941                    }
942                }
943                all_items.extend(child_items);
944
945                // Preserve attributes from original PureMod if available
946                let mod_attrs = ast_registry
947                    .get(mod_id)
948                    .and_then(|item| {
949                        if let PureItem::Mod(m) = item {
950                            Some(m.attrs.clone())
951                        } else {
952                            None
953                        }
954                    })
955                    .unwrap_or_default();
956
957                items.push(PureItem::Mod(PureMod {
958                    attrs: mod_attrs,
959                    vis,
960                    name: mod_name,
961                    items: all_items,
962                }));
963            }
964        }
965
966        // NOTE: merge_impl_blocks disabled for sequential impl numbering
967        // merge_impl_blocks(&mut items);
968        sort_items(&mut items);
969        items
970    }
971
972    /// Recursively generate files from graph for multi-file mode.
973    fn generate_file_from_graph(
974        &self,
975        current_path: &str,
976        ids: &[SymbolId],
977        code_graph: &CodeGraphV2,
978        ast_registry: &ASTRegistry,
979        symbol_registry: &SymbolRegistry,
980        files: &mut HashMap<String, GeneratedFile>,
981    ) -> Result<(), ToSynError> {
982        let mut items = Vec::new();
983
984        // Separate Mod symbols from other items
985        let mut mods: Vec<SymbolId> = Vec::new();
986        let mut non_mods: Vec<SymbolId> = Vec::new();
987
988        for &id in ids {
989            if let Some(kind) = symbol_registry.kind(id) {
990                if kind == SymbolKind::Mod {
991                    // Check if this Mod is actually a crate root (not a real submodule)
992                    // Use SymbolPath::is_crate_root() which uses precomputed crate_root_depth
993                    if let Some(path) = symbol_registry.path(id) {
994                        if path.is_crate_root() {
995                            // Crate root Mod: treat as non-mod (inline its contents)
996                            // Examples: main::my_app, my_lib
997                            non_mods.push(id);
998                        } else {
999                            // Real submodule: separate file
1000                            // Examples: main::my_app::utils, my_lib::utils
1001                            mods.push(id);
1002                        }
1003                    } else {
1004                        mods.push(id);
1005                    }
1006                } else {
1007                    non_mods.push(id);
1008                }
1009            }
1010        }
1011
1012        // Add non-mod items (excluding impl blocks)
1013        // Impl blocks are added from module_items below
1014        for id in non_mods {
1015            // Skip impl blocks - they're handled separately from module_items
1016            if symbol_registry.kind(id) == Some(SymbolKind::Impl) {
1017                continue;
1018            }
1019            if let Some(item) = ast_registry.get(id) {
1020                items.push(item.clone());
1021            }
1022        }
1023
1024        // Add use statements and impl blocks from module_items for the root level
1025        // New design: impl blocks are stored in module_items (file-level construct)
1026        for &id in ids {
1027            if let Some(module_items) = ast_registry.get_module_items(id) {
1028                for item in module_items {
1029                    if matches!(item, PureItem::Use(_) | PureItem::Impl(_)) {
1030                        items.push(item.clone());
1031                    }
1032                }
1033            }
1034        }
1035
1036        // Add mod declarations or inline modules for children
1037        // Track which modules are inline (should not generate separate files)
1038        let mut inline_mod_ids: Vec<SymbolId> = Vec::new();
1039
1040        for &mod_id in &mods {
1041            if let Some(path) = symbol_registry.path(mod_id) {
1042                let mod_name = path.segments().last().unwrap_or("unknown").to_string();
1043
1044                // Check if this is an inline module (has items in ASTRegistry)
1045                if let Some(PureItem::Mod(m)) = ast_registry.get(mod_id) {
1046                    if !m.items.is_empty() {
1047                        // Inline module - include as-is with its content
1048                        items.push(PureItem::Mod(m.clone()));
1049                        inline_mod_ids.push(mod_id);
1050                        continue;
1051                    }
1052                }
1053
1054                // External module - generate mod declaration
1055                // Use visibility from original PureMod if available,
1056                // NOT inferred from children (fixes mod → pub mod bug)
1057                let (vis, attrs) = ast_registry
1058                    .get(mod_id)
1059                    .and_then(|item| {
1060                        if let PureItem::Mod(m) = item {
1061                            Some((m.vis.clone(), m.attrs.clone()))
1062                        } else {
1063                            None
1064                        }
1065                    })
1066                    .unwrap_or((PureVis::Private, vec![]));
1067
1068                items.push(PureItem::Mod(PureMod {
1069                    attrs,
1070                    vis,
1071                    name: mod_name,
1072                    items: vec![], // External module
1073                }));
1074            }
1075        }
1076
1077        // NOTE: merge_impl_blocks is disabled to preserve sequential impl numbering.
1078        // With the new impl numbering system (<impl Foo>::1, <impl Foo>::2),
1079        // each impl block is intentionally separate and should not be merged.
1080        // merge_impl_blocks(&mut items);
1081
1082        // Sort items for stable output
1083        sort_items(&mut items);
1084
1085        // Extract inner attributes (#![...]) from module for file-level attrs
1086        let file_attrs = self.extract_file_attrs_from_ids(ids, ast_registry, symbol_registry);
1087
1088        let pure_file = PureFile {
1089            attrs: file_attrs,
1090            items,
1091        };
1092        let source = pure_file.to_source()?;
1093        files.insert(
1094            current_path.to_string(),
1095            GeneratedFile { source, pure_file },
1096        );
1097
1098        // Recursively generate child module files (skip inline modules)
1099        for mod_id in mods {
1100            // Skip inline modules - they don't need separate files
1101            if inline_mod_ids.contains(&mod_id) {
1102                continue;
1103            }
1104
1105            if let Some(path) = symbol_registry.path(mod_id) {
1106                let mod_name = path.segments().last().unwrap_or("unknown").to_string();
1107                let child_path = self.derive_child_path(current_path, &mod_name);
1108                let children: Vec<SymbolId> = code_graph.children_of(mod_id).collect();
1109                self.generate_file_from_graph(
1110                    &child_path,
1111                    &children,
1112                    code_graph,
1113                    ast_registry,
1114                    symbol_registry,
1115                    files,
1116                )?;
1117            }
1118        }
1119        Ok(())
1120    }
1121
1122    /// Generate a single file with nested mod {} blocks.
1123    ///
1124    /// - `is_main`: If true, output to `src/main.rs`, otherwise `src/lib.rs`
1125    fn generate_single_file_inner(
1126        &self,
1127        crate_name: &str,
1128        symbols: Vec<(SymbolId, SymbolPath, PureItem)>,
1129        is_main: bool,
1130        module_use_items: &HashMap<ModSymbolKey, Vec<PureItem>>,
1131        mod_symbols: &HashMap<ModSymbolKey, ModSymbolValue>,
1132    ) -> Result<HashMap<String, GeneratedFile>, ToSynError> {
1133        // Build module tree from symbols
1134        let mut tree = self.build_module_tree(crate_name, &symbols, is_main);
1135
1136        // Add empty modules from SymbolRegistry (modules without child items)
1137        self.add_mod_symbols_to_tree(&mut tree, crate_name, is_main, mod_symbols);
1138
1139        // Add use statements from module_items
1140        self.add_use_items_to_tree(&mut tree, crate_name, is_main, module_use_items);
1141
1142        // Convert tree to PureFile
1143        let pure_file = self.tree_to_pure_file(&tree);
1144        let source = pure_file.to_source()?;
1145
1146        let root_file = if is_main { "src/main.rs" } else { "src/lib.rs" };
1147
1148        let mut files = HashMap::new();
1149        files.insert(root_file.to_string(), GeneratedFile { source, pure_file });
1150        Ok(files)
1151    }
1152
1153    /// Generate multiple files following Rust module conventions.
1154    ///
1155    /// - `is_main`: If true, root is `src/main.rs`, otherwise `src/lib.rs`
1156    /// - `affected_file_keys`: When `Some`, only files in the set are serialized.
1157    fn generate_multi_file_inner(
1158        &self,
1159        crate_name: &str,
1160        symbols: Vec<SymbolEntry>,
1161        opts: MultiFileOpts<'_>,
1162    ) -> Result<HashMap<String, GeneratedFile>, ToSynError> {
1163        let MultiFileOpts {
1164            is_main,
1165            module_use_items,
1166            mod_symbols,
1167            inline_module_paths,
1168            affected_file_keys,
1169        } = opts;
1170
1171        // Build module tree from symbols
1172        let mut tree = self.build_module_tree(crate_name, &symbols, is_main);
1173
1174        // Add empty modules from SymbolRegistry (modules without child items)
1175        self.add_mod_symbols_to_tree(&mut tree, crate_name, is_main, mod_symbols);
1176
1177        // Add use statements from module_items
1178        self.add_use_items_to_tree(&mut tree, crate_name, is_main, module_use_items);
1179
1180        let root_file = if is_main { "src/main.rs" } else { "src/lib.rs" };
1181
1182        // Extract inline module names (just the last segment) for this crate
1183        let crate_prefix = format!("{}::", crate_name);
1184        let inline_mod_names: HashSet<String> = inline_module_paths
1185            .iter()
1186            .filter(|p| p.starts_with(&crate_prefix))
1187            .filter_map(|p| p.strip_prefix(&crate_prefix))
1188            .map(|s| {
1189                // Get just the module name (last segment)
1190                s.split("::").last().unwrap_or(s).to_string()
1191            })
1192            .collect();
1193
1194        // Generate files from tree
1195        let mut files = HashMap::new();
1196        self.generate_files_from_tree(
1197            &tree,
1198            root_file,
1199            &mut files,
1200            &inline_mod_names,
1201            affected_file_keys,
1202        )?;
1203        Ok(files)
1204    }
1205
1206    /// Add use statements from module_items to the module tree.
1207    fn add_use_items_to_tree(
1208        &self,
1209        tree: &mut ModuleNode,
1210        crate_name: &str,
1211        is_main: bool,
1212        module_use_items: &HashMap<ModSymbolKey, Vec<PureItem>>,
1213    ) {
1214        tracing::debug!(
1215            "add_use_items_to_tree: crate_name={}, is_main={}, module_use_items_count={}",
1216            crate_name,
1217            is_main,
1218            module_use_items.len()
1219        );
1220        // Collect matching use items first
1221        let mut items_to_add: Vec<(Vec<String>, Vec<PureItem>)> = Vec::new();
1222
1223        for ((item_crate, segments, item_is_main), use_items) in module_use_items {
1224            if item_crate != crate_name || *item_is_main != is_main {
1225                continue;
1226            }
1227            tracing::debug!(
1228                "  Matched: segments={:?}, use_items_count={}",
1229                segments,
1230                use_items.len()
1231            );
1232            items_to_add.push((segments.clone(), use_items.clone()));
1233        }
1234
1235        // Now add them to the tree
1236        for (segments, use_items) in items_to_add {
1237            let target_node = if segments.is_empty() {
1238                tree as &mut ModuleNode
1239            } else {
1240                let seg_refs: Vec<&str> = segments.iter().map(|s| s.as_str()).collect();
1241                tree.get_or_create_path(&seg_refs)
1242            };
1243
1244            // Add use items (they will be sorted later)
1245            for use_item in use_items {
1246                target_node.items.push(use_item);
1247            }
1248        }
1249    }
1250
1251    /// Add empty modules from SymbolRegistry to the module tree.
1252    ///
1253    /// This ensures modules without child items (empty modules) still get
1254    /// mod declarations in the output. RegistryGenerator derives module
1255    /// hierarchy from child item paths, so empty modules would otherwise
1256    /// be missing from the output.
1257    fn add_mod_symbols_to_tree(
1258        &self,
1259        tree: &mut ModuleNode,
1260        crate_name: &str,
1261        is_main: bool,
1262        mod_symbols: &HashMap<ModSymbolKey, ModSymbolValue>,
1263    ) {
1264        for ((item_crate, segments, item_is_main), (is_pub, attrs)) in mod_symbols {
1265            if item_crate != crate_name || *item_is_main != is_main {
1266                continue;
1267            }
1268            // Get target node: root tree for empty segments, or navigate to path
1269            let node = if segments.is_empty() {
1270                // Crate root - set attrs on the root tree node itself
1271                tree as &mut ModuleNode
1272            } else {
1273                let seg_refs: Vec<&str> = segments.iter().map(|s| s.as_str()).collect();
1274                tree.get_or_create_path(&seg_refs)
1275            };
1276
1277            // Set explicit visibility for empty modules (skip for root)
1278            if !segments.is_empty() && node.is_pub.is_none() {
1279                node.is_pub = Some(*is_pub);
1280            }
1281            // Set attributes (both outer #[...] and inner #![...])
1282            if node.attrs.is_empty() && !attrs.is_empty() {
1283                node.attrs = attrs.clone();
1284            }
1285        }
1286    }
1287
1288    /// Build a module tree from symbols.
1289    ///
1290    /// - `is_main`: If true, paths are `main::crate::module::Item` format
1291    fn build_module_tree(
1292        &self,
1293        crate_name: &str,
1294        symbols: &[(SymbolId, SymbolPath, PureItem)],
1295        is_main: bool,
1296    ) -> ModuleNode {
1297        let mut root = ModuleNode::new(crate_name.to_string());
1298
1299        for (_id, path, item) in symbols {
1300            // Get module path (all segments except crate name and item name)
1301            let segments: Vec<&str> = path.segments().collect();
1302
1303            // For main symbols: main::crate_name::module::Item
1304            // For lib symbols:  crate_name::module::Item
1305            let skip_count = if is_main { 2 } else { 1 }; // Skip "main::" + "crate" or just "crate"
1306
1307            if segments.len() <= skip_count {
1308                // Crate-level item (shouldn't happen normally)
1309                continue;
1310            }
1311
1312            // module_segments = segments between crate and item name
1313            // Filter out impl block segments (e.g., "<impl Trait for Type>")
1314            let module_segments: Vec<&str> = segments[skip_count..segments.len() - 1]
1315                .iter()
1316                .filter(|s| !s.starts_with("<impl "))
1317                .copied()
1318                .collect();
1319
1320            // Navigate/create module path
1321            let target_module = root.get_or_create_path(&module_segments);
1322
1323            // Add item to module
1324            target_module.items.push(item.clone());
1325        }
1326
1327        root
1328    }
1329
1330    /// Convert module tree to PureFile (single file with nested mods).
1331    fn tree_to_pure_file(&self, tree: &ModuleNode) -> PureFile {
1332        let items = self.collect_items_recursive(tree, true);
1333        // Extract inner attributes (#![...]) from root node for file-level attrs
1334        let file_attrs: Vec<PureAttribute> =
1335            tree.attrs.iter().filter(|a| a.is_inner).cloned().collect();
1336        PureFile {
1337            attrs: file_attrs,
1338            items,
1339        }
1340    }
1341
1342    /// Collect items recursively, creating nested mod {} blocks.
1343    fn collect_items_recursive(&self, node: &ModuleNode, _is_root: bool) -> Vec<PureItem> {
1344        let mut items = Vec::new();
1345
1346        // Add direct items (sorted), filtering out file-based mod declarations (content: None)
1347        // but keeping inline modules (items non-empty). File-based mods are generated from
1348        // node.children below to avoid duplicates.
1349        let mut direct_items: Vec<PureItem> = node
1350            .items
1351            .iter()
1352            .filter(|item| match item {
1353                PureItem::Mod(m) => !m.items.is_empty(), // Keep inline modules
1354                _ => true,
1355            })
1356            .cloned()
1357            .collect();
1358        // NOTE: merge_impl_blocks disabled for sequential impl numbering
1359        // merge_impl_blocks(&mut direct_items);
1360        sort_items(&mut direct_items);
1361        items.extend(direct_items);
1362
1363        // Add child modules as nested mod {}
1364        for (name, child) in &node.children {
1365            let child_items = self.collect_items_recursive(child, false);
1366
1367            // Determine visibility: explicit flag takes precedence, otherwise check items
1368            let vis = if let Some(is_pub) = child.is_pub {
1369                if is_pub {
1370                    PureVis::Public
1371                } else {
1372                    PureVis::Private
1373                }
1374            } else if child.items.iter().any(is_public) {
1375                PureVis::Public
1376            } else {
1377                PureVis::Private
1378            };
1379
1380            // Use outer attributes (#[...]) for module declaration
1381            let mod_attrs: Vec<PureAttribute> = child
1382                .attrs
1383                .iter()
1384                .filter(|a| !a.is_inner)
1385                .cloned()
1386                .collect();
1387
1388            items.push(PureItem::Mod(PureMod {
1389                attrs: mod_attrs,
1390                vis,
1391                name: name.clone(),
1392                items: child_items,
1393            }));
1394        }
1395
1396        items
1397    }
1398
1399    /// Generate files from tree (multi-file mode).
1400    ///
1401    /// `known_inline_mods`: Set of module names that are known inline modules from ASTRegistry.
1402    /// These modules should NOT generate separate files, even if they appear in tree.children.
1403    ///
1404    /// `affected_file_keys`: When `Some`, only serialize files whose crate-relative path
1405    /// is in the set. The tree walk still recurses for all children (to handle nested modules),
1406    /// but `to_source()` is skipped for non-affected files.
1407    fn generate_files_from_tree(
1408        &self,
1409        tree: &ModuleNode,
1410        current_path: &str,
1411        files: &mut HashMap<String, GeneratedFile>,
1412        known_inline_mods: &HashSet<String>,
1413        affected_file_keys: Option<&HashSet<String>>,
1414    ) -> Result<(), ToSynError> {
1415        tracing::debug!(
1416            "Generating file: {} (module: {}, {} children, {} items, known_inline_mods={:?})",
1417            current_path,
1418            tree.name,
1419            tree.children.len(),
1420            tree.items.len(),
1421            known_inline_mods
1422        );
1423
1424        // Collect items for this module (without children content)
1425        let mut items = Vec::new();
1426
1427        // Add direct items (structs, enums, functions, etc.)
1428        // Only keep modules that are explicitly marked as inline via mark_inline_module().
1429        // External modules (not marked) get separate files, even if PureMod.items is non-empty.
1430        // This fixes the bug where external module files (e.g., filter.rs) would get inlined
1431        // into lib.rs just because their PureMod.items was non-empty after parsing.
1432        let mut direct_items: Vec<PureItem> = tree
1433            .items
1434            .iter()
1435            .filter(|item| match item {
1436                PureItem::Mod(m) => {
1437                    // Only keep modules explicitly marked as inline in ASTRegistry
1438                    known_inline_mods.contains(&m.name)
1439                }
1440                _ => true,
1441            })
1442            .cloned()
1443            .collect();
1444        // NOTE: merge_impl_blocks disabled for sequential impl numbering
1445        // merge_impl_blocks(&mut direct_items);
1446        sort_items(&mut direct_items);
1447
1448        // Use only ASTRegistry's inline module markers for inline detection.
1449        // This prevents external modules from being incorrectly treated as inline
1450        // just because their PureMod.items is non-empty (e.g., parsed from filter.rs).
1451        let inline_mod_names: HashSet<String> = known_inline_mods.clone();
1452
1453        items.extend(direct_items);
1454
1455        // Add mod declarations for children (no content - separate files)
1456        // Skip children that are already inline modules (avoid duplicates)
1457        for (name, child) in &tree.children {
1458            if inline_mod_names.contains(name) {
1459                continue;
1460            }
1461
1462            // Try to get visibility from tree.items (original PureMod)
1463            // This preserves the original `mod foo;` vs `pub mod foo;` distinction
1464            let original_mod = tree.items.iter().find_map(|item| {
1465                if let PureItem::Mod(m) = item {
1466                    if &m.name == name {
1467                        return Some(m);
1468                    }
1469                }
1470                None
1471            });
1472
1473            // Use original visibility if available, then explicit flag, then infer from items
1474            let vis = if let Some(m) = original_mod {
1475                m.vis.clone()
1476            } else if let Some(is_pub) = child.is_pub {
1477                if is_pub {
1478                    PureVis::Public
1479                } else {
1480                    PureVis::Private
1481                }
1482            } else {
1483                PureVis::Private
1484            };
1485
1486            // Use outer attributes (#[...]) for module declaration (e.g., #[cfg(test)] mod tests;)
1487            let mod_attrs: Vec<PureAttribute> = if let Some(m) = original_mod {
1488                m.attrs.iter().filter(|a| !a.is_inner).cloned().collect()
1489            } else {
1490                child
1491                    .attrs
1492                    .iter()
1493                    .filter(|a| !a.is_inner)
1494                    .cloned()
1495                    .collect()
1496            };
1497
1498            items.push(PureItem::Mod(PureMod {
1499                attrs: mod_attrs,
1500                vis,
1501                name: name.clone(),
1502                items: vec![], // External module
1503            }));
1504        }
1505
1506        // Create file for this module — but ONLY if it's in the affected set (or no filter).
1507        //
1508        // DESIGN RULE: to_source() is the most expensive operation in the generation pipeline
1509        // (prettyplease formatting). Skipping it for unaffected files is the primary optimization.
1510        let is_affected = affected_file_keys
1511            .map(|keys| keys.contains(current_path))
1512            .unwrap_or(true);
1513
1514        if is_affected {
1515            let file_attrs: Vec<PureAttribute> =
1516                tree.attrs.iter().filter(|a| a.is_inner).cloned().collect();
1517            let pure_file = PureFile {
1518                attrs: file_attrs,
1519                items,
1520            };
1521            let source = pure_file.to_source()?;
1522            files.insert(
1523                current_path.to_string(),
1524                GeneratedFile { source, pure_file },
1525            );
1526        } else {
1527            tracing::debug!("Skipping unaffected file: {}", current_path);
1528        }
1529
1530        // Recursively generate child files (skip inline modules - they don't need separate files)
1531        // NOTE: Always recurse even when current file is unaffected — child files may be affected.
1532        for (name, child) in &tree.children {
1533            if inline_mod_names.contains(name) {
1534                tracing::debug!("Skipping inline module {} (no separate file)", name);
1535                continue;
1536            }
1537            let child_path = self.derive_child_path(current_path, name);
1538            tracing::debug!("  → Child module: {} at {}", name, child_path);
1539            self.generate_files_from_tree(
1540                child,
1541                &child_path,
1542                files,
1543                known_inline_mods,
1544                affected_file_keys,
1545            )?;
1546        }
1547        Ok(())
1548    }
1549
1550    /// Derive child module file path.
1551    fn derive_child_path(&self, parent_path: &str, child_name: &str) -> String {
1552        // parent_path: "src/lib.rs" or "src/models.rs" or "src/models/user.rs"
1553        let parent_dir = if parent_path.ends_with("/lib.rs") || parent_path.ends_with("/main.rs") {
1554            // Root: src/lib.rs → src/
1555            parent_path
1556                .trim_end_matches("lib.rs")
1557                .trim_end_matches("main.rs")
1558        } else if parent_path.ends_with("/mod.rs") {
1559            // mod.rs style: src/models/mod.rs → src/models/
1560            parent_path.trim_end_matches("mod.rs")
1561        } else {
1562            // Modern style: src/models.rs → src/models/
1563            parent_path.trim_end_matches(".rs")
1564        };
1565
1566        if self.use_mod_rs {
1567            // Legacy: child → parent_dir/child/mod.rs
1568            format!("{}{}/mod.rs", parent_dir, child_name)
1569        } else {
1570            // Modern: child → parent_dir/child.rs (or parent_dir + child.rs for root)
1571            if parent_path.ends_with("/lib.rs") || parent_path.ends_with("/main.rs") {
1572                format!("{}{}.rs", parent_dir, child_name)
1573            } else {
1574                format!("{}/{}.rs", parent_dir, child_name)
1575            }
1576        }
1577    }
1578
1579    /// Extract inner attributes (#![...]) from symbol IDs for file-level attrs.
1580    ///
1581    /// Looks for crate root Mod symbols or the first Mod symbol in the list
1582    /// and extracts their inner attributes.
1583    fn extract_file_attrs_from_ids(
1584        &self,
1585        ids: &[SymbolId],
1586        ast_registry: &ASTRegistry,
1587        symbol_registry: &SymbolRegistry,
1588    ) -> Vec<PureAttribute> {
1589        for &id in ids {
1590            // Check if this is a crate root Mod
1591            if let Some(kind) = symbol_registry.kind(id) {
1592                if kind == SymbolKind::Mod {
1593                    if let Some(path) = symbol_registry.path(id) {
1594                        if path.is_crate_root() {
1595                            // Found crate root, extract inner attrs
1596                            if let Some(PureItem::Mod(m)) = ast_registry.get(id) {
1597                                return m.attrs.iter().filter(|a| a.is_inner).cloned().collect();
1598                            }
1599                        }
1600                    }
1601                }
1602            }
1603        }
1604        Vec::new()
1605    }
1606}
1607
1608/// Internal module tree node.
1609#[derive(Debug, Clone, Default)]
1610struct ModuleNode {
1611    name: String,
1612    items: Vec<PureItem>,
1613    children: BTreeMap<String, ModuleNode>,
1614    /// Explicit visibility override (for empty modules from SymbolRegistry)
1615    is_pub: Option<bool>,
1616    /// Module attributes (both outer `#[...]` and inner `#![...]`).
1617    /// - `is_inner: false` → module declaration attrs (e.g., `#[cfg(test)] mod tests;`)
1618    /// - `is_inner: true` → file-level inner attrs (e.g., `#![allow(...)]`)
1619    attrs: Vec<PureAttribute>,
1620}
1621
1622impl ModuleNode {
1623    fn new(name: String) -> Self {
1624        Self {
1625            name,
1626            items: Vec::new(),
1627            children: BTreeMap::new(),
1628            is_pub: None,
1629            attrs: Vec::new(),
1630        }
1631    }
1632
1633    fn get_or_create_path(&mut self, segments: &[&str]) -> &mut ModuleNode {
1634        if segments.is_empty() {
1635            return self;
1636        }
1637
1638        let child = self
1639            .children
1640            .entry(segments[0].to_string())
1641            .or_insert_with(|| ModuleNode::new(segments[0].to_string()));
1642
1643        child.get_or_create_path(&segments[1..])
1644    }
1645}
1646
1647/// Sort items for idiomatic Rust ordering.
1648///
1649/// Order:
1650/// 1. Use statements
1651/// 2. Definitions (const, static, type, struct, enum, trait, fn)
1652/// 3. Impl blocks
1653/// 4. Modules (except tests)
1654/// 5. Macros
1655/// 6. Tests module (always last)
1656fn sort_items(items: &mut [PureItem]) {
1657    items.sort_by(|a, b| {
1658        let kind_a = item_sort_key(a);
1659        let kind_b = item_sort_key(b);
1660
1661        match kind_a.cmp(&kind_b) {
1662            std::cmp::Ordering::Equal => {
1663                let name_cmp = item_name(a).cmp(item_name(b));
1664                if name_cmp != std::cmp::Ordering::Equal {
1665                    return name_cmp;
1666                }
1667                // For impl blocks with same self_ty, sort by trait presence
1668                // impl Foo (no trait) comes before impl Trait for Foo
1669                match (a, b) {
1670                    (PureItem::Impl(a_impl), PureItem::Impl(b_impl)) => {
1671                        match (&a_impl.trait_, &b_impl.trait_) {
1672                            (None, None) => std::cmp::Ordering::Equal,
1673                            (None, Some(_)) => std::cmp::Ordering::Less,
1674                            (Some(_), None) => std::cmp::Ordering::Greater,
1675                            (Some(a_trait), Some(b_trait)) => a_trait.cmp(b_trait),
1676                        }
1677                    }
1678                    _ => std::cmp::Ordering::Equal,
1679                }
1680            }
1681            other => other,
1682        }
1683    });
1684}
1685
1686fn item_sort_key(item: &PureItem) -> u8 {
1687    match item {
1688        PureItem::Use(_) => 0,
1689        PureItem::Const(_) => 1,
1690        PureItem::Static(_) => 2,
1691        PureItem::Type(_) => 3,
1692        PureItem::Struct(_) => 4,
1693        PureItem::Enum(_) => 5,
1694        PureItem::Trait(_) => 6,
1695        PureItem::Fn(_) => 7,
1696        PureItem::Impl(_) => 8,
1697        PureItem::Mod(m) if m.name == "tests" => 11, // tests module last
1698        PureItem::Mod(_) => 9,
1699        PureItem::Macro(_) => 10,
1700        PureItem::Other(_) => 12,
1701    }
1702}
1703
1704fn item_name(item: &PureItem) -> &str {
1705    match item {
1706        PureItem::Struct(s) => &s.name,
1707        PureItem::Enum(e) => &e.name,
1708        PureItem::Fn(f) => &f.name,
1709        PureItem::Trait(t) => &t.name,
1710        PureItem::Impl(i) => &i.self_ty,
1711        PureItem::Const(c) => &c.name,
1712        PureItem::Static(s) => &s.name,
1713        PureItem::Type(t) => &t.name,
1714        PureItem::Mod(m) => &m.name,
1715        PureItem::Macro(m) => &m.path,
1716        _ => "",
1717    }
1718}
1719
1720fn is_public(item: &PureItem) -> bool {
1721    match item {
1722        PureItem::Struct(s) => matches!(s.vis, PureVis::Public),
1723        PureItem::Enum(e) => matches!(e.vis, PureVis::Public),
1724        PureItem::Fn(f) => matches!(f.vis, PureVis::Public),
1725        PureItem::Trait(t) => matches!(t.vis, PureVis::Public),
1726        PureItem::Const(c) => matches!(c.vis, PureVis::Public),
1727        PureItem::Static(s) => matches!(s.vis, PureVis::Public),
1728        PureItem::Type(t) => matches!(t.vis, PureVis::Public),
1729        PureItem::Mod(m) => matches!(m.vis, PureVis::Public),
1730        _ => false,
1731    }
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736    use super::*;
1737    use ryo_source::pure::{
1738        PureBlock, PureEnum, PureFields, PureFn, PureGenerics, PureStruct, PureVariant,
1739    };
1740    use ryo_symbol::SymbolKind;
1741
1742    fn make_struct(name: &str) -> PureItem {
1743        PureItem::Struct(PureStruct {
1744            attrs: vec![],
1745            vis: PureVis::Public,
1746            name: name.to_string(),
1747            generics: PureGenerics::default(),
1748            fields: PureFields::Unit,
1749        })
1750    }
1751
1752    fn make_fn(name: &str) -> PureItem {
1753        PureItem::Fn(PureFn {
1754            attrs: vec![],
1755            vis: PureVis::Public,
1756            is_async: false,
1757            is_async_inferred: false,
1758            is_const: false,
1759            is_unsafe: false,
1760            abi: None,
1761            name: name.to_string(),
1762            generics: PureGenerics::default(),
1763            params: vec![],
1764            ret: None,
1765            body: PureBlock::default(),
1766        })
1767    }
1768
1769    fn make_path(s: &str) -> SymbolPath {
1770        SymbolPath::parse(s).unwrap()
1771    }
1772
1773    #[test]
1774    fn test_single_file_basic() {
1775        let mut ast_registry = ASTRegistry::new();
1776        let mut symbol_registry = SymbolRegistry::new();
1777
1778        // Register symbols
1779        let config_path = make_path("my_crate::Config");
1780        let config_id = symbol_registry
1781            .register(config_path, SymbolKind::Struct)
1782            .unwrap();
1783        ast_registry.set(config_id, make_struct("Config"));
1784
1785        let helper_path = make_path("my_crate::helper");
1786        let helper_id = symbol_registry
1787            .register(helper_path, SymbolKind::Function)
1788            .unwrap();
1789        ast_registry.set(helper_id, make_fn("helper"));
1790
1791        // Generate
1792        let generator = RegistryGenerator::single_file();
1793        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
1794
1795        assert_eq!(workspace.crates.len(), 1);
1796        let crate_data = workspace.crates.get("my_crate").unwrap();
1797        assert_eq!(crate_data.files.len(), 1);
1798
1799        let lib = crate_data.files.get("src/lib.rs").unwrap();
1800        assert!(lib.source.contains("struct Config"));
1801        assert!(lib.source.contains("fn helper"));
1802    }
1803
1804    #[test]
1805    fn test_single_file_nested_modules() {
1806        let mut ast_registry = ASTRegistry::new();
1807        let mut symbol_registry = SymbolRegistry::new();
1808
1809        // my_crate::Config
1810        let config_path = make_path("my_crate::Config");
1811        let config_id = symbol_registry
1812            .register(config_path, SymbolKind::Struct)
1813            .unwrap();
1814        ast_registry.set(config_id, make_struct("Config"));
1815
1816        // my_crate::models::User
1817        let user_path = make_path("my_crate::models::User");
1818        let user_id = symbol_registry
1819            .register(user_path, SymbolKind::Struct)
1820            .unwrap();
1821        ast_registry.set(user_id, make_struct("User"));
1822
1823        // my_crate::models::dto::UserDto
1824        let dto_path = make_path("my_crate::models::dto::UserDto");
1825        let dto_id = symbol_registry
1826            .register(dto_path, SymbolKind::Struct)
1827            .unwrap();
1828        ast_registry.set(dto_id, make_struct("UserDto"));
1829
1830        // Generate
1831        let generator = RegistryGenerator::single_file();
1832        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
1833
1834        let lib = workspace
1835            .crates
1836            .get("my_crate")
1837            .unwrap()
1838            .files
1839            .get("src/lib.rs")
1840            .unwrap();
1841
1842        // Verify structure
1843        assert!(lib.source.contains("struct Config"));
1844        assert!(lib.source.contains("mod models"));
1845        assert!(lib.source.contains("struct User"));
1846        assert!(lib.source.contains("mod dto"));
1847        assert!(lib.source.contains("struct UserDto"));
1848
1849        // Verify it's valid Rust
1850        syn::parse_str::<syn::File>(&lib.source)
1851            .unwrap_or_else(|_| panic!("Should be valid Rust:\n{}", lib.source));
1852    }
1853
1854    #[test]
1855    fn test_multi_file_modern_style() {
1856        let mut ast_registry = ASTRegistry::new();
1857        let mut symbol_registry = SymbolRegistry::new();
1858
1859        // my_crate::Config
1860        let config_path = make_path("my_crate::Config");
1861        let config_id = symbol_registry
1862            .register(config_path, SymbolKind::Struct)
1863            .unwrap();
1864        ast_registry.set(config_id, make_struct("Config"));
1865
1866        // my_crate::models::User
1867        let user_path = make_path("my_crate::models::User");
1868        let user_id = symbol_registry
1869            .register(user_path, SymbolKind::Struct)
1870            .unwrap();
1871        ast_registry.set(user_id, make_struct("User"));
1872
1873        // Generate
1874        let generator = RegistryGenerator::multi_file();
1875        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
1876
1877        let crate_data = workspace.crates.get("my_crate").unwrap();
1878
1879        // Should have: lib.rs and models.rs
1880        assert_eq!(crate_data.files.len(), 2);
1881        assert!(crate_data.files.contains_key("src/lib.rs"));
1882        assert!(crate_data.files.contains_key("src/models.rs"));
1883
1884        // lib.rs has Config and mod models declaration
1885        let lib = crate_data.files.get("src/lib.rs").unwrap();
1886        assert!(lib.source.contains("struct Config"));
1887        assert!(lib.source.contains("mod models;"));
1888
1889        // models.rs has User
1890        let models = crate_data.files.get("src/models.rs").unwrap();
1891        assert!(models.source.contains("struct User"));
1892    }
1893
1894    #[test]
1895    fn test_multi_crate() {
1896        let mut ast_registry = ASTRegistry::new();
1897        let mut symbol_registry = SymbolRegistry::new();
1898
1899        // crate_a::Foo
1900        let foo_path = make_path("crate_a::Foo");
1901        let foo_id = symbol_registry
1902            .register(foo_path, SymbolKind::Struct)
1903            .unwrap();
1904        ast_registry.set(foo_id, make_struct("Foo"));
1905
1906        // crate_b::Bar
1907        let bar_path = make_path("crate_b::Bar");
1908        let bar_id = symbol_registry
1909            .register(bar_path, SymbolKind::Struct)
1910            .unwrap();
1911        ast_registry.set(bar_id, make_struct("Bar"));
1912
1913        // Generate
1914        let generator = RegistryGenerator::single_file();
1915        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
1916
1917        // Should have 2 crates
1918        assert_eq!(workspace.crates.len(), 2);
1919        assert!(workspace.crates.contains_key("crate_a"));
1920        assert!(workspace.crates.contains_key("crate_b"));
1921
1922        // Each with their respective symbols
1923        let crate_a = workspace.crates.get("crate_a").unwrap();
1924        assert!(crate_a
1925            .files
1926            .get("src/lib.rs")
1927            .unwrap()
1928            .source
1929            .contains("struct Foo"));
1930
1931        let crate_b = workspace.crates.get("crate_b").unwrap();
1932        assert!(crate_b
1933            .files
1934            .get("src/lib.rs")
1935            .unwrap()
1936            .source
1937            .contains("struct Bar"));
1938    }
1939
1940    #[test]
1941    fn test_all_generated_valid_rust() {
1942        let mut ast_registry = ASTRegistry::new();
1943        let mut symbol_registry = SymbolRegistry::new();
1944
1945        // Build a complex structure
1946        for (path, name) in [
1947            ("app::Config", "Config"),
1948            ("app::models::User", "User"),
1949            ("app::models::Post", "Post"),
1950            ("app::models::dto::UserDto", "UserDto"),
1951            ("app::utils::helpers::format", "format"),
1952        ] {
1953            let sp = make_path(path);
1954            let kind = if name == "format" {
1955                SymbolKind::Function
1956            } else {
1957                SymbolKind::Struct
1958            };
1959            let id = symbol_registry.register(sp, kind).unwrap();
1960            if name == "format" {
1961                ast_registry.set(id, make_fn(name));
1962            } else {
1963                ast_registry.set(id, make_struct(name));
1964            }
1965        }
1966
1967        // Test both single and multi file
1968        for generator in [
1969            RegistryGenerator::single_file(),
1970            RegistryGenerator::multi_file(),
1971            RegistryGenerator::multi_file_mod_rs(),
1972        ] {
1973            let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
1974
1975            for (crate_name, path, file) in workspace.iter_files() {
1976                syn::parse_str::<syn::File>(&file.source).unwrap_or_else(|_| {
1977                    panic!(
1978                        "[{}] {} should be valid Rust:\n{}",
1979                        crate_name, path, file.source
1980                    )
1981                });
1982            }
1983        }
1984    }
1985
1986    // ========================================================================
1987    // AddMod / RemoveMod Tests - No Span Manipulation Required!
1988    // ========================================================================
1989
1990    /// Test: Adding a new module by just registering symbols.
1991    ///
1992    /// This proves that file creation is automatic from SymbolPath -
1993    /// no span manipulation or file creation needed.
1994    #[test]
1995    fn test_add_mod_no_span_required() {
1996        let mut ast_registry = ASTRegistry::new();
1997        let mut symbol_registry = SymbolRegistry::new();
1998
1999        // Initial state: just Config at root
2000        let config_id = symbol_registry
2001            .register(make_path("app::Config"), SymbolKind::Struct)
2002            .unwrap();
2003        ast_registry.set(config_id, make_struct("Config"));
2004
2005        // Generate initial state
2006        let generator = RegistryGenerator::multi_file();
2007        let before = generator.generate(&ast_registry, &symbol_registry).unwrap();
2008
2009        // Before: only lib.rs
2010        assert_eq!(before.crates.get("app").unwrap().files.len(), 1);
2011        assert!(before
2012            .crates
2013            .get("app")
2014            .unwrap()
2015            .files
2016            .contains_key("src/lib.rs"));
2017
2018        // === AddMod: Just register new symbols under new module path ===
2019        // No span manipulation! No file creation! Just registry operations!
2020        let user_id = symbol_registry
2021            .register(make_path("app::models::User"), SymbolKind::Struct)
2022            .unwrap();
2023        ast_registry.set(user_id, make_struct("User"));
2024
2025        let post_id = symbol_registry
2026            .register(make_path("app::models::Post"), SymbolKind::Struct)
2027            .unwrap();
2028        ast_registry.set(post_id, make_struct("Post"));
2029
2030        // Generate after AddMod
2031        let after = generator.generate(&ast_registry, &symbol_registry).unwrap();
2032
2033        // After: lib.rs AND models.rs automatically created!
2034        let app = after.crates.get("app").unwrap();
2035        assert_eq!(app.files.len(), 2);
2036        assert!(app.files.contains_key("src/lib.rs"));
2037        assert!(app.files.contains_key("src/models.rs")); // NEW FILE - auto generated!
2038
2039        // lib.rs now has mod declaration
2040        let lib = app.files.get("src/lib.rs").unwrap();
2041        assert!(lib.source.contains("struct Config"));
2042        assert!(lib.source.contains("mod models;"));
2043
2044        // models.rs has the new symbols
2045        let models = app.files.get("src/models.rs").unwrap();
2046        assert!(models.source.contains("struct User"));
2047        assert!(models.source.contains("struct Post"));
2048
2049        // All valid Rust
2050        syn::parse_str::<syn::File>(&lib.source).unwrap();
2051        syn::parse_str::<syn::File>(&models.source).unwrap();
2052    }
2053
2054    /// Test: Adding deeply nested modules automatically creates file hierarchy.
2055    #[test]
2056    fn test_add_nested_mod_no_span_required() {
2057        let mut ast_registry = ASTRegistry::new();
2058        let mut symbol_registry = SymbolRegistry::new();
2059
2060        // Initial: empty crate (just need one symbol to have a crate)
2061        let config_id = symbol_registry
2062            .register(make_path("app::Config"), SymbolKind::Struct)
2063            .unwrap();
2064        ast_registry.set(config_id, make_struct("Config"));
2065
2066        // Add deeply nested module: app::api::v1::handlers::UserHandler
2067        let handler_id = symbol_registry
2068            .register(
2069                make_path("app::api::v1::handlers::UserHandler"),
2070                SymbolKind::Struct,
2071            )
2072            .unwrap();
2073        ast_registry.set(handler_id, make_struct("UserHandler"));
2074
2075        // Generate
2076        let generator = RegistryGenerator::multi_file();
2077        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2078
2079        let app = workspace.crates.get("app").unwrap();
2080
2081        // Should have: lib.rs, api.rs, api/v1.rs, api/v1/handlers.rs
2082        assert_eq!(app.files.len(), 4);
2083        assert!(app.files.contains_key("src/lib.rs"));
2084        assert!(app.files.contains_key("src/api.rs"));
2085        assert!(app.files.contains_key("src/api/v1.rs"));
2086        assert!(app.files.contains_key("src/api/v1/handlers.rs"));
2087
2088        // Verify chain of mod declarations
2089        assert!(app
2090            .files
2091            .get("src/lib.rs")
2092            .unwrap()
2093            .source
2094            .contains("mod api;"));
2095        assert!(app
2096            .files
2097            .get("src/api.rs")
2098            .unwrap()
2099            .source
2100            .contains("mod v1;"));
2101        assert!(app
2102            .files
2103            .get("src/api/v1.rs")
2104            .unwrap()
2105            .source
2106            .contains("mod handlers;"));
2107        assert!(app
2108            .files
2109            .get("src/api/v1/handlers.rs")
2110            .unwrap()
2111            .source
2112            .contains("struct UserHandler"));
2113
2114        // All valid Rust
2115        for file in app.files.values() {
2116            syn::parse_str::<syn::File>(&file.source).unwrap();
2117        }
2118    }
2119
2120    /// Test: Removing a module by removing symbols from registries.
2121    ///
2122    /// This proves that file deletion is automatic - when no symbols exist
2123    /// under a path, the file disappears from generation.
2124    #[test]
2125    fn test_remove_mod_no_span_required() {
2126        let mut ast_registry = ASTRegistry::new();
2127        let mut symbol_registry = SymbolRegistry::new();
2128
2129        // Initial state: Config + models module with User
2130        let config_id = symbol_registry
2131            .register(make_path("app::Config"), SymbolKind::Struct)
2132            .unwrap();
2133        ast_registry.set(config_id, make_struct("Config"));
2134
2135        let user_id = symbol_registry
2136            .register(make_path("app::models::User"), SymbolKind::Struct)
2137            .unwrap();
2138        ast_registry.set(user_id, make_struct("User"));
2139
2140        // Generate before removal
2141        let generator = RegistryGenerator::multi_file();
2142        let before = generator.generate(&ast_registry, &symbol_registry).unwrap();
2143
2144        // Before: lib.rs + models.rs
2145        assert_eq!(before.crates.get("app").unwrap().files.len(), 2);
2146
2147        // === RemoveMod: Just remove symbols from registries ===
2148        // No span manipulation! No file deletion! Just registry operations!
2149        ast_registry.remove(user_id);
2150        symbol_registry.remove(user_id);
2151
2152        // Generate after RemoveMod
2153        let after = generator.generate(&ast_registry, &symbol_registry).unwrap();
2154
2155        // After: only lib.rs (models.rs automatically gone!)
2156        let app = after.crates.get("app").unwrap();
2157        assert_eq!(app.files.len(), 1);
2158        assert!(app.files.contains_key("src/lib.rs"));
2159        assert!(!app.files.contains_key("src/models.rs")); // REMOVED - auto!
2160
2161        // lib.rs no longer has mod declaration
2162        let lib = app.files.get("src/lib.rs").unwrap();
2163        assert!(lib.source.contains("struct Config"));
2164        assert!(!lib.source.contains("mod models")); // mod declaration gone!
2165
2166        syn::parse_str::<syn::File>(&lib.source).unwrap();
2167    }
2168
2169    /// Test: Partial module removal (some symbols remain).
2170    #[test]
2171    fn test_partial_remove_mod() {
2172        let mut ast_registry = ASTRegistry::new();
2173        let mut symbol_registry = SymbolRegistry::new();
2174
2175        // Initial: models with User and Post
2176        let config_id = symbol_registry
2177            .register(make_path("app::Config"), SymbolKind::Struct)
2178            .unwrap();
2179        ast_registry.set(config_id, make_struct("Config"));
2180
2181        let user_id = symbol_registry
2182            .register(make_path("app::models::User"), SymbolKind::Struct)
2183            .unwrap();
2184        ast_registry.set(user_id, make_struct("User"));
2185
2186        let post_id = symbol_registry
2187            .register(make_path("app::models::Post"), SymbolKind::Struct)
2188            .unwrap();
2189        ast_registry.set(post_id, make_struct("Post"));
2190
2191        // Remove only User, keep Post
2192        ast_registry.remove(user_id);
2193        symbol_registry.remove(user_id);
2194
2195        // Generate
2196        let generator = RegistryGenerator::multi_file();
2197        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2198
2199        let app = workspace.crates.get("app").unwrap();
2200
2201        // models.rs still exists (Post remains)
2202        assert_eq!(app.files.len(), 2);
2203        assert!(app.files.contains_key("src/models.rs"));
2204
2205        let models = app.files.get("src/models.rs").unwrap();
2206        assert!(!models.source.contains("struct User")); // User removed
2207        assert!(models.source.contains("struct Post")); // Post remains
2208
2209        syn::parse_str::<syn::File>(&models.source).unwrap();
2210    }
2211
2212    // ========================================================================
2213    // Main Entry Point Tests (main:: prefix)
2214    // ========================================================================
2215
2216    /// Test: Binary entry point (main::crate::...) outputs to main.rs
2217    #[test]
2218    fn test_main_entry_point() {
2219        let mut ast_registry = ASTRegistry::new();
2220        let mut symbol_registry = SymbolRegistry::new();
2221
2222        // Library symbol: my_crate::Config -> lib.rs
2223        let config_id = symbol_registry
2224            .register(make_path("my_crate::Config"), SymbolKind::Struct)
2225            .unwrap();
2226        ast_registry.set(config_id, make_struct("Config"));
2227
2228        // Binary symbol: main::my_crate::main -> main.rs
2229        let main_id = symbol_registry
2230            .register(make_path("main::my_crate::main"), SymbolKind::Function)
2231            .unwrap();
2232        ast_registry.set(main_id, make_fn("main"));
2233
2234        // Generate
2235        let generator = RegistryGenerator::multi_file();
2236        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2237
2238        // Should have one crate with both lib.rs and main.rs
2239        assert_eq!(workspace.crates.len(), 1);
2240        let crate_data = workspace.crates.get("my_crate").unwrap();
2241
2242        assert!(crate_data.files.contains_key("src/lib.rs"));
2243        assert!(crate_data.files.contains_key("src/main.rs"));
2244
2245        // lib.rs has Config
2246        let lib = crate_data.files.get("src/lib.rs").unwrap();
2247        assert!(lib.source.contains("struct Config"));
2248        assert!(!lib.source.contains("fn main"));
2249
2250        // main.rs has main function
2251        let main = crate_data.files.get("src/main.rs").unwrap();
2252        assert!(main.source.contains("fn main"));
2253        assert!(!main.source.contains("struct Config"));
2254
2255        // Both valid Rust
2256        syn::parse_str::<syn::File>(&lib.source).unwrap();
2257        syn::parse_str::<syn::File>(&main.source).unwrap();
2258    }
2259
2260    /// Test: main.rs with nested modules
2261    #[test]
2262    fn test_main_with_modules() {
2263        let mut ast_registry = ASTRegistry::new();
2264        let mut symbol_registry = SymbolRegistry::new();
2265
2266        // main::my_crate::main
2267        let main_id = symbol_registry
2268            .register(make_path("main::my_crate::main"), SymbolKind::Function)
2269            .unwrap();
2270        ast_registry.set(main_id, make_fn("main"));
2271
2272        // main::my_crate::cli::Args (module in main.rs)
2273        let args_id = symbol_registry
2274            .register(make_path("main::my_crate::cli::Args"), SymbolKind::Struct)
2275            .unwrap();
2276        ast_registry.set(args_id, make_struct("Args"));
2277
2278        // Generate (single file mode)
2279        let generator = RegistryGenerator::single_file();
2280        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2281
2282        let crate_data = workspace.crates.get("my_crate").unwrap();
2283        let main = crate_data.files.get("src/main.rs").unwrap();
2284
2285        // main.rs should have main function and cli module
2286        assert!(main.source.contains("fn main"));
2287        assert!(main.source.contains("mod cli"));
2288        assert!(main.source.contains("struct Args"));
2289
2290        syn::parse_str::<syn::File>(&main.source).unwrap();
2291    }
2292
2293    /// Test: Multi-file mode with main.rs modules
2294    #[test]
2295    fn test_main_multi_file_modules() {
2296        let mut ast_registry = ASTRegistry::new();
2297        let mut symbol_registry = SymbolRegistry::new();
2298
2299        // main::my_crate::main
2300        let main_id = symbol_registry
2301            .register(make_path("main::my_crate::main"), SymbolKind::Function)
2302            .unwrap();
2303        ast_registry.set(main_id, make_fn("main"));
2304
2305        // main::my_crate::cli::Args
2306        let args_id = symbol_registry
2307            .register(make_path("main::my_crate::cli::Args"), SymbolKind::Struct)
2308            .unwrap();
2309        ast_registry.set(args_id, make_struct("Args"));
2310
2311        // Generate (multi-file mode)
2312        let generator = RegistryGenerator::multi_file();
2313        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2314
2315        let crate_data = workspace.crates.get("my_crate").unwrap();
2316
2317        // Should have main.rs and cli.rs
2318        assert!(crate_data.files.contains_key("src/main.rs"));
2319        assert!(crate_data.files.contains_key("src/cli.rs"));
2320
2321        // main.rs has main and mod declaration
2322        let main = crate_data.files.get("src/main.rs").unwrap();
2323        assert!(main.source.contains("fn main"));
2324        assert!(main.source.contains("mod cli;"));
2325
2326        // cli.rs has Args
2327        let cli = crate_data.files.get("src/cli.rs").unwrap();
2328        assert!(cli.source.contains("struct Args"));
2329
2330        // All valid Rust
2331        syn::parse_str::<syn::File>(&main.source).unwrap();
2332        syn::parse_str::<syn::File>(&cli.source).unwrap();
2333    }
2334
2335    /// Test: Both lib.rs and main.rs with separate module trees
2336    #[test]
2337    fn test_lib_and_main_separate_modules() {
2338        let mut ast_registry = ASTRegistry::new();
2339        let mut symbol_registry = SymbolRegistry::new();
2340
2341        // Library: my_crate::lib_mod::LibStruct
2342        let lib_id = symbol_registry
2343            .register(
2344                make_path("my_crate::lib_mod::LibStruct"),
2345                SymbolKind::Struct,
2346            )
2347            .unwrap();
2348        ast_registry.set(lib_id, make_struct("LibStruct"));
2349
2350        // Binary: main::my_crate::bin_mod::BinStruct
2351        let bin_id = symbol_registry
2352            .register(
2353                make_path("main::my_crate::bin_mod::BinStruct"),
2354                SymbolKind::Struct,
2355            )
2356            .unwrap();
2357        ast_registry.set(bin_id, make_struct("BinStruct"));
2358
2359        // Generate
2360        let generator = RegistryGenerator::multi_file();
2361        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2362
2363        let crate_data = workspace.crates.get("my_crate").unwrap();
2364
2365        // Should have 4 files: lib.rs, lib_mod.rs, main.rs, bin_mod.rs
2366        assert_eq!(crate_data.files.len(), 4);
2367        assert!(crate_data.files.contains_key("src/lib.rs"));
2368        assert!(crate_data.files.contains_key("src/lib_mod.rs"));
2369        assert!(crate_data.files.contains_key("src/main.rs"));
2370        assert!(crate_data.files.contains_key("src/bin_mod.rs"));
2371
2372        // lib.rs has mod lib_mod
2373        let lib = crate_data.files.get("src/lib.rs").unwrap();
2374        assert!(lib.source.contains("mod lib_mod;"));
2375        assert!(!lib.source.contains("mod bin_mod"));
2376
2377        // main.rs has mod bin_mod
2378        let main = crate_data.files.get("src/main.rs").unwrap();
2379        assert!(main.source.contains("mod bin_mod;"));
2380        assert!(!main.source.contains("mod lib_mod"));
2381
2382        // Modules have correct content
2383        assert!(crate_data
2384            .files
2385            .get("src/lib_mod.rs")
2386            .unwrap()
2387            .source
2388            .contains("struct LibStruct"));
2389        assert!(crate_data
2390            .files
2391            .get("src/bin_mod.rs")
2392            .unwrap()
2393            .source
2394            .contains("struct BinStruct"));
2395
2396        // All valid Rust
2397        for file in crate_data.files.values() {
2398            syn::parse_str::<syn::File>(&file.source).unwrap();
2399        }
2400    }
2401
2402    /// Test: Move symbol between modules (remove from one, add to another).
2403    #[test]
2404    fn test_move_symbol_between_modules() {
2405        let mut ast_registry = ASTRegistry::new();
2406        let mut symbol_registry = SymbolRegistry::new();
2407
2408        // Initial: User in models
2409        let config_id = symbol_registry
2410            .register(make_path("app::Config"), SymbolKind::Struct)
2411            .unwrap();
2412        ast_registry.set(config_id, make_struct("Config"));
2413
2414        let user_id = symbol_registry
2415            .register(make_path("app::models::User"), SymbolKind::Struct)
2416            .unwrap();
2417        ast_registry.set(user_id, make_struct("User"));
2418
2419        let generator = RegistryGenerator::multi_file();
2420
2421        // Before: User in models
2422        let before = generator.generate(&ast_registry, &symbol_registry).unwrap();
2423        assert!(before
2424            .crates
2425            .get("app")
2426            .unwrap()
2427            .files
2428            .contains_key("src/models.rs"));
2429
2430        // === Move: Remove from models, add to entities ===
2431        ast_registry.remove(user_id);
2432        symbol_registry.remove(user_id);
2433
2434        let new_user_id = symbol_registry
2435            .register(make_path("app::entities::User"), SymbolKind::Struct)
2436            .unwrap();
2437        ast_registry.set(new_user_id, make_struct("User"));
2438
2439        // After: User in entities, models gone
2440        let after = generator.generate(&ast_registry, &symbol_registry).unwrap();
2441        let app = after.crates.get("app").unwrap();
2442
2443        assert!(!app.files.contains_key("src/models.rs")); // Old module gone
2444        assert!(app.files.contains_key("src/entities.rs")); // New module created
2445
2446        let entities = app.files.get("src/entities.rs").unwrap();
2447        assert!(entities.source.contains("struct User"));
2448
2449        // lib.rs updated
2450        let lib = app.files.get("src/lib.rs").unwrap();
2451        assert!(!lib.source.contains("mod models"));
2452        assert!(lib.source.contains("mod entities;"));
2453
2454        syn::parse_str::<syn::File>(&lib.source).unwrap();
2455        syn::parse_str::<syn::File>(&entities.source).unwrap();
2456    }
2457
2458    // ========================================================================
2459    // Adapter Method Tests (dump_to_workspace_files)
2460    // ========================================================================
2461    // CodeGraphV2-based generation tests (generate_with_graph)
2462    // ========================================================================
2463
2464    use ryo_analysis::{CodeEdgeV2, CodeGraphV2};
2465
2466    fn setup_registries_with_graph() -> (ASTRegistry, SymbolRegistry, CodeGraphV2) {
2467        let mut ast_registry = ASTRegistry::new();
2468        let mut symbol_registry = SymbolRegistry::new();
2469        let mut code_graph = CodeGraphV2::new();
2470
2471        // Register crate root module
2472        let crate_id = symbol_registry
2473            .register(make_path("my_crate"), SymbolKind::Mod)
2474            .unwrap();
2475        code_graph.add_node(crate_id);
2476        code_graph.add_crate_root(crate_id);
2477
2478        // Root level struct
2479        let config_id = symbol_registry
2480            .register(make_path("my_crate::Config"), SymbolKind::Struct)
2481            .unwrap();
2482        ast_registry.set(config_id, make_struct("Config"));
2483        code_graph.add_node(config_id);
2484        code_graph.add_edge(crate_id, config_id, CodeEdgeV2::Contains);
2485
2486        // Module
2487        let models_id = symbol_registry
2488            .register(make_path("my_crate::models"), SymbolKind::Mod)
2489            .unwrap();
2490        code_graph.add_node(models_id);
2491        code_graph.add_edge(crate_id, models_id, CodeEdgeV2::Contains);
2492
2493        // Item in module
2494        let user_id = symbol_registry
2495            .register(make_path("my_crate::models::User"), SymbolKind::Struct)
2496            .unwrap();
2497        ast_registry.set(user_id, make_struct("User"));
2498        code_graph.add_node(user_id);
2499        code_graph.add_edge(models_id, user_id, CodeEdgeV2::Contains);
2500
2501        (ast_registry, symbol_registry, code_graph)
2502    }
2503
2504    #[test]
2505    fn test_generate_with_graph_single_file() {
2506        let (ast_registry, symbol_registry, code_graph) = setup_registries_with_graph();
2507
2508        let generator = RegistryGenerator::single_file();
2509        let workspace = generator
2510            .generate_with_graph(&code_graph, &ast_registry, &symbol_registry)
2511            .unwrap();
2512
2513        assert_eq!(workspace.crates.len(), 1);
2514        let crate_data = workspace.crates.get("my_crate").unwrap();
2515        assert_eq!(crate_data.files.len(), 1);
2516
2517        let lib = crate_data.files.get("src/lib.rs").unwrap();
2518        assert!(lib.source.contains("struct Config"), "Should have Config");
2519        assert!(lib.source.contains("mod models"), "Should have mod models");
2520        assert!(
2521            lib.source.contains("struct User"),
2522            "Should have User in nested mod"
2523        );
2524
2525        // Verify valid Rust
2526        syn::parse_str::<syn::File>(&lib.source)
2527            .unwrap_or_else(|_| panic!("Should be valid Rust:\n{}", lib.source));
2528    }
2529
2530    #[test]
2531    fn test_generate_with_graph_multi_file() {
2532        let (ast_registry, symbol_registry, code_graph) = setup_registries_with_graph();
2533
2534        let generator = RegistryGenerator::multi_file();
2535        let workspace = generator
2536            .generate_with_graph(&code_graph, &ast_registry, &symbol_registry)
2537            .unwrap();
2538
2539        assert_eq!(workspace.crates.len(), 1);
2540        let crate_data = workspace.crates.get("my_crate").unwrap();
2541        assert_eq!(
2542            crate_data.files.len(),
2543            2,
2544            "Should have lib.rs and models.rs"
2545        );
2546
2547        let lib = crate_data.files.get("src/lib.rs").unwrap();
2548        assert!(
2549            lib.source.contains("struct Config"),
2550            "lib.rs should have Config"
2551        );
2552        assert!(
2553            lib.source.contains("mod models;"),
2554            "lib.rs should have mod declaration"
2555        );
2556
2557        let models = crate_data.files.get("src/models.rs").unwrap();
2558        assert!(
2559            models.source.contains("struct User"),
2560            "models.rs should have User"
2561        );
2562
2563        // Verify valid Rust
2564        syn::parse_str::<syn::File>(&lib.source).unwrap();
2565        syn::parse_str::<syn::File>(&models.source).unwrap();
2566    }
2567
2568    #[test]
2569    fn test_generate_with_graph_matches_original() {
2570        let (ast_registry, symbol_registry, code_graph) = setup_registries_with_graph();
2571
2572        let generator = RegistryGenerator::single_file();
2573
2574        // Generate with both methods
2575        let original = generator.generate(&ast_registry, &symbol_registry).unwrap();
2576        let with_graph = generator
2577            .generate_with_graph(&code_graph, &ast_registry, &symbol_registry)
2578            .unwrap();
2579
2580        // Same number of crates
2581        assert_eq!(original.crates.len(), with_graph.crates.len());
2582
2583        // Same crate names
2584        for crate_name in original.crates.keys() {
2585            assert!(with_graph.crates.contains_key(crate_name));
2586        }
2587    }
2588
2589    // ========================================================================
2590    // SameAST → SameOutput Tests - Inline Test Module (Idempotent Generation)
2591    // ========================================================================
2592
2593    use ryo_source::pure::{
2594        PureAttrMeta, PureAttribute, PureField, PureImpl, PureImplItem, PureType, PureUse,
2595        PureUseTree,
2596    };
2597
2598    /// Build inline test module AST manually for testing generator output
2599    fn make_inline_test_module() -> PureMod {
2600        PureMod {
2601            attrs: vec![PureAttribute {
2602                path: "cfg".to_string(),
2603                meta: PureAttrMeta::List("test".to_string()),
2604                is_inner: false,
2605            }],
2606            vis: PureVis::Private,
2607            name: "tests".to_string(),
2608            items: vec![
2609                PureItem::Use(PureUse {
2610                    vis: PureVis::Private,
2611                    tree: PureUseTree::Path {
2612                        path: "super".to_string(),
2613                        tree: Box::new(PureUseTree::Glob),
2614                    },
2615                }),
2616                PureItem::Fn(PureFn {
2617                    attrs: vec![PureAttribute {
2618                        path: "test".to_string(),
2619                        meta: PureAttrMeta::Path,
2620                        is_inner: false,
2621                    }],
2622                    vis: PureVis::Private,
2623                    is_async: false,
2624                    is_async_inferred: false,
2625                    is_const: false,
2626                    is_unsafe: false,
2627                    abi: None,
2628                    name: "test_config".to_string(),
2629                    generics: PureGenerics::default(),
2630                    params: vec![],
2631                    ret: None,
2632                    body: PureBlock::default(),
2633                }),
2634            ],
2635        }
2636    }
2637
2638    /// Test: #[cfg(test)] inline module outputs correctly with attributes preserved
2639    #[test]
2640    fn test_inline_test_module_idempotent_output() {
2641        let mut ast_registry = ASTRegistry::new();
2642        let mut symbol_registry = SymbolRegistry::new();
2643
2644        // Register crate root
2645        symbol_registry
2646            .register(make_path("my_crate"), SymbolKind::Mod)
2647            .unwrap();
2648
2649        // Register and set Config struct
2650        let config_id = symbol_registry
2651            .register(make_path("my_crate::Config"), SymbolKind::Struct)
2652            .unwrap();
2653        ast_registry.set(config_id, make_struct("Config"));
2654
2655        // Register tests module and mark as inline
2656        let tests_id = symbol_registry
2657            .register(make_path("my_crate::tests"), SymbolKind::Mod)
2658            .unwrap();
2659        ast_registry.set(tests_id, PureItem::Mod(make_inline_test_module()));
2660        ast_registry.mark_inline_module(tests_id);
2661
2662        // Generate output
2663        let generator = RegistryGenerator::single_file();
2664        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2665
2666        let crate_data = workspace.crates.get("my_crate").unwrap();
2667        let lib = crate_data.files.get("src/lib.rs").unwrap();
2668
2669        // Verify output is valid Rust
2670        syn::parse_str::<syn::File>(&lib.source)
2671            .unwrap_or_else(|_| panic!("Should be valid Rust:\n{}", lib.source));
2672
2673        // Verify Config struct is present
2674        assert!(
2675            lib.source.contains("struct Config"),
2676            "Output should contain Config struct"
2677        );
2678
2679        // Verify tests module is inline (not mod tests;)
2680        assert!(
2681            lib.source.contains("mod tests {"),
2682            "Output should have inline mod tests, got:\n{}",
2683            lib.source
2684        );
2685
2686        // Verify #[cfg(test)] attribute is preserved
2687        assert!(
2688            lib.source.contains("#[cfg(test)]"),
2689            "Output should preserve #[cfg(test)] attribute, got:\n{}",
2690            lib.source
2691        );
2692
2693        // Verify test_config function is present
2694        assert!(
2695            lib.source.contains("fn test_config"),
2696            "Output should contain test_config function"
2697        );
2698
2699        // Verify #[test] attribute is preserved
2700        assert!(
2701            lib.source.contains("#[test]"),
2702            "Output should preserve #[test] attribute, got:\n{}",
2703            lib.source
2704        );
2705
2706        // === Idempotency test: generate again, should be identical ===
2707        let workspace2 = generator.generate(&ast_registry, &symbol_registry).unwrap();
2708        let lib2 = workspace2
2709            .crates
2710            .get("my_crate")
2711            .unwrap()
2712            .files
2713            .get("src/lib.rs")
2714            .unwrap();
2715
2716        assert_eq!(
2717            lib.source, lib2.source,
2718            "Idempotent: same AST should produce identical output"
2719        );
2720    }
2721
2722    /// Test: Inline module with impl block outputs correctly
2723    #[test]
2724    fn test_inline_module_with_impl_idempotent_output() {
2725        let mut ast_registry = ASTRegistry::new();
2726        let mut symbol_registry = SymbolRegistry::new();
2727
2728        symbol_registry
2729            .register(make_path("my_crate"), SymbolKind::Mod)
2730            .unwrap();
2731
2732        let config_id = symbol_registry
2733            .register(make_path("my_crate::Config"), SymbolKind::Struct)
2734            .unwrap();
2735        ast_registry.set(config_id, make_struct("Config"));
2736
2737        // Build tests module with struct + impl
2738        let tests_mod = PureMod {
2739            attrs: vec![PureAttribute {
2740                path: "cfg".to_string(),
2741                meta: PureAttrMeta::List("test".to_string()),
2742                is_inner: false,
2743            }],
2744            vis: PureVis::Private,
2745            name: "tests".to_string(),
2746            items: vec![
2747                PureItem::Struct(PureStruct {
2748                    attrs: vec![],
2749                    vis: PureVis::Private,
2750                    name: "TestHelper".to_string(),
2751                    generics: PureGenerics::default(),
2752                    fields: PureFields::Named(vec![PureField {
2753                        attrs: vec![],
2754                        vis: PureVis::Private,
2755                        name: "value".to_string(),
2756                        ty: PureType::Path("i32".to_string()),
2757                    }]),
2758                }),
2759                PureItem::Impl(PureImpl {
2760                    attrs: vec![],
2761                    generics: PureGenerics::default(),
2762                    is_unsafe: false,
2763                    trait_: None,
2764                    self_ty: "TestHelper".to_string(),
2765                    items: vec![PureImplItem::Fn(PureFn {
2766                        attrs: vec![],
2767                        vis: PureVis::Private,
2768                        is_async: false,
2769                        is_async_inferred: false,
2770                        is_const: false,
2771                        is_unsafe: false,
2772                        abi: None,
2773                        name: "new".to_string(),
2774                        generics: PureGenerics::default(),
2775                        params: vec![],
2776                        ret: Some(PureType::Path("Self".to_string())),
2777                        body: PureBlock::default(),
2778                    })],
2779                }),
2780                PureItem::Fn(PureFn {
2781                    attrs: vec![PureAttribute {
2782                        path: "test".to_string(),
2783                        meta: PureAttrMeta::Path,
2784                        is_inner: false,
2785                    }],
2786                    vis: PureVis::Private,
2787                    is_async: false,
2788                    is_async_inferred: false,
2789                    is_const: false,
2790                    is_unsafe: false,
2791                    abi: None,
2792                    name: "test_with_helper".to_string(),
2793                    generics: PureGenerics::default(),
2794                    params: vec![],
2795                    ret: None,
2796                    body: PureBlock::default(),
2797                }),
2798            ],
2799        };
2800
2801        let tests_id = symbol_registry
2802            .register(make_path("my_crate::tests"), SymbolKind::Mod)
2803            .unwrap();
2804        ast_registry.set(tests_id, PureItem::Mod(tests_mod));
2805        ast_registry.mark_inline_module(tests_id);
2806
2807        let generator = RegistryGenerator::single_file();
2808        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2809
2810        let crate_data = workspace.crates.get("my_crate").unwrap();
2811        let lib = crate_data.files.get("src/lib.rs").unwrap();
2812
2813        // Verify valid Rust
2814        syn::parse_str::<syn::File>(&lib.source)
2815            .unwrap_or_else(|_| panic!("Should be valid Rust:\n{}", lib.source));
2816
2817        // Verify structure
2818        assert!(lib.source.contains("struct Config"));
2819        assert!(lib.source.contains("#[cfg(test)]"));
2820        assert!(lib.source.contains("mod tests {"));
2821        assert!(lib.source.contains("struct TestHelper"));
2822        assert!(lib.source.contains("impl TestHelper"));
2823        assert!(lib.source.contains("fn new"));
2824        assert!(lib.source.contains("fn test_with_helper"));
2825
2826        // Idempotency
2827        let workspace2 = generator.generate(&ast_registry, &symbol_registry).unwrap();
2828        let lib2 = workspace2
2829            .crates
2830            .get("my_crate")
2831            .unwrap()
2832            .files
2833            .get("src/lib.rs")
2834            .unwrap();
2835        assert_eq!(lib.source, lib2.source, "Idempotent output");
2836    }
2837
2838    /// Test: Multi-file mode keeps inline modules inline (doesn't create separate files)
2839    #[test]
2840    fn test_inline_module_multi_file_no_separate_file() {
2841        let mut ast_registry = ASTRegistry::new();
2842        let mut symbol_registry = SymbolRegistry::new();
2843
2844        symbol_registry
2845            .register(make_path("my_crate"), SymbolKind::Mod)
2846            .unwrap();
2847
2848        let config_id = symbol_registry
2849            .register(make_path("my_crate::Config"), SymbolKind::Struct)
2850            .unwrap();
2851        ast_registry.set(config_id, make_struct("Config"));
2852
2853        // External module (utils) - will get separate file
2854        let utils_id = symbol_registry
2855            .register(make_path("my_crate::utils"), SymbolKind::Mod)
2856            .unwrap();
2857        // Set empty PureMod (external module declaration)
2858        ast_registry.set(
2859            utils_id,
2860            PureItem::Mod(PureMod {
2861                attrs: vec![],
2862                vis: PureVis::Public,
2863                name: "utils".to_string(),
2864                items: vec![], // Empty = external module
2865            }),
2866        );
2867
2868        let helper_id = symbol_registry
2869            .register(make_path("my_crate::utils::helper"), SymbolKind::Function)
2870            .unwrap();
2871        ast_registry.set(helper_id, make_fn("helper"));
2872
2873        // Inline module (tests) - stays inline
2874        let tests_id = symbol_registry
2875            .register(make_path("my_crate::tests"), SymbolKind::Mod)
2876            .unwrap();
2877        ast_registry.set(tests_id, PureItem::Mod(make_inline_test_module()));
2878        ast_registry.mark_inline_module(tests_id);
2879
2880        // Use multi-file mode
2881        let generator = RegistryGenerator::multi_file();
2882        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2883
2884        let crate_data = workspace.crates.get("my_crate").unwrap();
2885
2886        // Should have lib.rs and utils.rs, but NOT tests.rs
2887        assert!(
2888            crate_data.files.contains_key("src/lib.rs"),
2889            "Should have lib.rs"
2890        );
2891        assert!(
2892            crate_data.files.contains_key("src/utils.rs"),
2893            "Should have utils.rs (external module)"
2894        );
2895        assert!(
2896            !crate_data.files.contains_key("src/tests.rs"),
2897            "Should NOT have tests.rs (inline module stays inline)"
2898        );
2899
2900        let lib = crate_data.files.get("src/lib.rs").unwrap();
2901
2902        // lib.rs should have inline tests module (not mod tests;)
2903        assert!(
2904            lib.source.contains("mod tests {"),
2905            "tests should be inline, got:\n{}",
2906            lib.source
2907        );
2908
2909        // lib.rs should have #[cfg(test)] attribute
2910        assert!(
2911            lib.source.contains("#[cfg(test)]"),
2912            "Should preserve #[cfg(test)], got:\n{}",
2913            lib.source
2914        );
2915
2916        // lib.rs should have mod utils; declaration (external)
2917        assert!(
2918            lib.source.contains("mod utils;"),
2919            "Should have mod utils; declaration, got:\n{}",
2920            lib.source
2921        );
2922
2923        // Verify all files are valid Rust
2924        for (path, file) in &crate_data.files {
2925            syn::parse_str::<syn::File>(&file.source)
2926                .unwrap_or_else(|_| panic!("[{}] Should be valid Rust:\n{}", path, file.source));
2927        }
2928    }
2929
2930    /// Test: External module declaration preserves attributes (#[cfg(test)] mod tests;)
2931    #[test]
2932    fn test_external_mod_preserves_attrs() {
2933        let mut ast_registry = ASTRegistry::new();
2934        let mut symbol_registry = SymbolRegistry::new();
2935
2936        // Register crate root
2937        symbol_registry
2938            .register(make_path("my_crate"), SymbolKind::Mod)
2939            .unwrap();
2940
2941        // Register external tests module with #[cfg(test)] attribute
2942        let tests_mod = PureMod {
2943            attrs: vec![PureAttribute {
2944                path: "cfg".to_string(),
2945                meta: PureAttrMeta::List("test".to_string()),
2946                is_inner: false, // Outer attribute for mod declaration
2947            }],
2948            vis: PureVis::Private,
2949            name: "tests".to_string(),
2950            items: vec![], // Empty = external module
2951        };
2952
2953        let tests_id = symbol_registry
2954            .register(make_path("my_crate::tests"), SymbolKind::Mod)
2955            .unwrap();
2956        ast_registry.set(tests_id, PureItem::Mod(tests_mod));
2957        // NOT marked as inline - this is an external module
2958
2959        // Register a struct inside tests module
2960        let helper_id = symbol_registry
2961            .register(make_path("my_crate::tests::TestHelper"), SymbolKind::Struct)
2962            .unwrap();
2963        ast_registry.set(helper_id, make_struct("TestHelper"));
2964
2965        // Generate multi-file
2966        let generator = RegistryGenerator::multi_file();
2967        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
2968
2969        let crate_data = workspace.crates.get("my_crate").unwrap();
2970
2971        // === Verify PureFile structure directly ===
2972        let lib = crate_data.files.get("src/lib.rs").unwrap();
2973
2974        // File-level attrs should be empty (no inner attrs on crate root)
2975        assert_eq!(
2976            lib.pure_file.attrs.len(),
2977            0,
2978            "lib.rs should have no file-level attrs, got: {:?}",
2979            lib.pure_file.attrs
2980        );
2981
2982        // Find mod tests declaration
2983        let tests_mod_item = lib
2984            .pure_file
2985            .items
2986            .iter()
2987            .find_map(|item| {
2988                if let PureItem::Mod(m) = item {
2989                    if m.name == "tests" {
2990                        return Some(m);
2991                    }
2992                }
2993                None
2994            })
2995            .expect("Should have mod tests in lib.rs");
2996
2997        // Verify mod tests is external (empty items)
2998        assert!(
2999            tests_mod_item.items.is_empty(),
3000            "External mod should have empty items"
3001        );
3002
3003        // Verify outer attribute count (exactly 1)
3004        assert_eq!(
3005            tests_mod_item.attrs.len(),
3006            1,
3007            "mod tests should have exactly 1 attribute, got: {:?}",
3008            tests_mod_item.attrs
3009        );
3010
3011        // Verify attribute content
3012        let attr = &tests_mod_item.attrs[0];
3013        assert_eq!(attr.path, "cfg", "Attr path should be 'cfg'");
3014        assert!(!attr.is_inner, "Should be outer attribute");
3015        assert!(
3016            matches!(&attr.meta, PureAttrMeta::List(s) if s == "test"),
3017            "Attr meta should be List(\"test\"), got: {:?}",
3018            attr.meta
3019        );
3020
3021        // Verify tests.rs exists
3022        assert!(
3023            crate_data.files.contains_key("src/tests.rs"),
3024            "Should have tests.rs for external module"
3025        );
3026
3027        // Verify all files are valid Rust
3028        for (path, file) in &crate_data.files {
3029            syn::parse_str::<syn::File>(&file.source)
3030                .unwrap_or_else(|_| panic!("[{}] Should be valid Rust:\n{}", path, file.source));
3031        }
3032    }
3033
3034    /// Test: Inner attributes (#![allow(...)]) are preserved at file level
3035    /// Verifies PureFile.attrs structure directly.
3036    #[test]
3037    fn test_inner_attrs_at_file_level() {
3038        let mut ast_registry = ASTRegistry::new();
3039        let mut symbol_registry = SymbolRegistry::new();
3040
3041        // Register crate root with inner attributes
3042        let crate_mod = PureMod {
3043            attrs: vec![
3044                PureAttribute {
3045                    path: "allow".to_string(),
3046                    meta: PureAttrMeta::List("dead_code".to_string()),
3047                    is_inner: true, // Inner attribute for file
3048                },
3049                PureAttribute {
3050                    path: "warn".to_string(),
3051                    meta: PureAttrMeta::List("unused_variables".to_string()),
3052                    is_inner: true,
3053                },
3054            ],
3055            vis: PureVis::Public,
3056            name: "my_crate".to_string(),
3057            items: vec![],
3058        };
3059
3060        let crate_id = symbol_registry
3061            .register(make_path("my_crate"), SymbolKind::Mod)
3062            .unwrap();
3063        ast_registry.set(crate_id, PureItem::Mod(crate_mod));
3064
3065        // Register a struct
3066        let config_id = symbol_registry
3067            .register(make_path("my_crate::Config"), SymbolKind::Struct)
3068            .unwrap();
3069        ast_registry.set(config_id, make_struct("Config"));
3070
3071        // Generate single file
3072        let generator = RegistryGenerator::single_file();
3073        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
3074
3075        let crate_data = workspace.crates.get("my_crate").unwrap();
3076        let lib = crate_data.files.get("src/lib.rs").unwrap();
3077
3078        // === Verify PureFile.attrs structure directly ===
3079        assert_eq!(
3080            lib.pure_file.attrs.len(),
3081            2,
3082            "lib.rs should have exactly 2 file-level attrs, got: {:?}",
3083            lib.pure_file.attrs
3084        );
3085
3086        // All file-level attrs should be inner
3087        for attr in &lib.pure_file.attrs {
3088            assert!(attr.is_inner, "File-level attr should be inner: {:?}", attr);
3089        }
3090
3091        // Verify first attr: #![allow(dead_code)]
3092        let attr0 = &lib.pure_file.attrs[0];
3093        assert_eq!(attr0.path, "allow");
3094        assert!(matches!(&attr0.meta, PureAttrMeta::List(s) if s == "dead_code"));
3095
3096        // Verify second attr: #![warn(unused_variables)]
3097        let attr1 = &lib.pure_file.attrs[1];
3098        assert_eq!(attr1.path, "warn");
3099        assert!(matches!(&attr1.meta, PureAttrMeta::List(s) if s == "unused_variables"));
3100
3101        // Verify valid Rust
3102        syn::parse_str::<syn::File>(&lib.source)
3103            .unwrap_or_else(|_| panic!("Should be valid Rust:\n{}", lib.source));
3104    }
3105
3106    /// Test: Mixed outer and inner attributes are correctly separated
3107    /// - Outer attrs (#[...]) → mod declaration
3108    /// - Inner attrs (#![...]) → file level of the module's file
3109    #[test]
3110    fn test_mixed_outer_inner_attrs_separation() {
3111        let mut ast_registry = ASTRegistry::new();
3112        let mut symbol_registry = SymbolRegistry::new();
3113
3114        // Register crate root
3115        symbol_registry
3116            .register(make_path("my_crate"), SymbolKind::Mod)
3117            .unwrap();
3118
3119        // Register utils module with BOTH outer and inner attributes
3120        let utils_mod = PureMod {
3121            attrs: vec![
3122                // Outer: goes to mod declaration in parent (#[doc = "..."] mod utils;)
3123                PureAttribute {
3124                    path: "doc".to_string(),
3125                    meta: PureAttrMeta::NameValue("\"Utils module\"".to_string()),
3126                    is_inner: false,
3127                },
3128                // Inner: goes to file level in utils.rs (#![allow(dead_code)])
3129                PureAttribute {
3130                    path: "allow".to_string(),
3131                    meta: PureAttrMeta::List("dead_code".to_string()),
3132                    is_inner: true,
3133                },
3134                // Another outer
3135                PureAttribute {
3136                    path: "cfg".to_string(),
3137                    meta: PureAttrMeta::List("feature = \"utils\"".to_string()),
3138                    is_inner: false,
3139                },
3140            ],
3141            vis: PureVis::Public,
3142            name: "utils".to_string(),
3143            items: vec![], // External module
3144        };
3145
3146        let utils_id = symbol_registry
3147            .register(make_path("my_crate::utils"), SymbolKind::Mod)
3148            .unwrap();
3149        ast_registry.set(utils_id, PureItem::Mod(utils_mod));
3150
3151        // Register a function inside utils
3152        let helper_fn_id = symbol_registry
3153            .register(make_path("my_crate::utils::helper"), SymbolKind::Function)
3154            .unwrap();
3155        ast_registry.set(helper_fn_id, make_fn("helper"));
3156
3157        // Generate multi-file
3158        let generator = RegistryGenerator::multi_file();
3159        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
3160
3161        let crate_data = workspace.crates.get("my_crate").unwrap();
3162
3163        // === Check lib.rs: mod utils declaration should have ONLY outer attrs ===
3164        let lib = crate_data.files.get("src/lib.rs").unwrap();
3165        let utils_mod_decl = lib
3166            .pure_file
3167            .items
3168            .iter()
3169            .find_map(|item| {
3170                if let PureItem::Mod(m) = item {
3171                    if m.name == "utils" {
3172                        return Some(m);
3173                    }
3174                }
3175                None
3176            })
3177            .expect("Should have mod utils in lib.rs");
3178
3179        // Should have exactly 2 outer attrs (doc and cfg)
3180        assert_eq!(
3181            utils_mod_decl.attrs.len(),
3182            2,
3183            "mod utils declaration should have 2 outer attrs, got: {:?}",
3184            utils_mod_decl.attrs
3185        );
3186
3187        // All should be outer (not inner)
3188        for attr in &utils_mod_decl.attrs {
3189            assert!(
3190                !attr.is_inner,
3191                "mod declaration attrs should be outer: {:?}",
3192                attr
3193            );
3194        }
3195
3196        // Verify specific attrs
3197        assert!(
3198            utils_mod_decl.attrs.iter().any(|a| a.path == "doc"),
3199            "Should have doc attr"
3200        );
3201        assert!(
3202            utils_mod_decl.attrs.iter().any(|a| a.path == "cfg"),
3203            "Should have cfg attr"
3204        );
3205
3206        // === Check utils.rs: should have inner attrs at file level ===
3207        let utils_file = crate_data
3208            .files
3209            .get("src/utils.rs")
3210            .expect("Should have utils.rs");
3211
3212        assert_eq!(
3213            utils_file.pure_file.attrs.len(),
3214            1,
3215            "utils.rs should have 1 file-level attr (inner), got: {:?}",
3216            utils_file.pure_file.attrs
3217        );
3218
3219        let file_attr = &utils_file.pure_file.attrs[0];
3220        assert!(file_attr.is_inner, "File attr should be inner");
3221        assert_eq!(file_attr.path, "allow");
3222        assert!(matches!(&file_attr.meta, PureAttrMeta::List(s) if s == "dead_code"));
3223
3224        // Verify all files are valid Rust
3225        for (path, file) in &crate_data.files {
3226            syn::parse_str::<syn::File>(&file.source)
3227                .unwrap_or_else(|_| panic!("[{}] Should be valid Rust:\n{}", path, file.source));
3228        }
3229    }
3230
3231    /// Test: No spurious attributes are added
3232    /// When source has no attrs, output should have no attrs.
3233    #[test]
3234    fn test_no_spurious_attrs() {
3235        let mut ast_registry = ASTRegistry::new();
3236        let mut symbol_registry = SymbolRegistry::new();
3237
3238        // Register crate root WITHOUT attrs
3239        let crate_mod = PureMod {
3240            attrs: vec![], // No attrs
3241            vis: PureVis::Public,
3242            name: "my_crate".to_string(),
3243            items: vec![],
3244        };
3245
3246        let crate_id = symbol_registry
3247            .register(make_path("my_crate"), SymbolKind::Mod)
3248            .unwrap();
3249        ast_registry.set(crate_id, PureItem::Mod(crate_mod));
3250
3251        // Register module WITHOUT attrs
3252        let utils_mod = PureMod {
3253            attrs: vec![], // No attrs
3254            vis: PureVis::Public,
3255            name: "utils".to_string(),
3256            items: vec![],
3257        };
3258
3259        let utils_id = symbol_registry
3260            .register(make_path("my_crate::utils"), SymbolKind::Mod)
3261            .unwrap();
3262        ast_registry.set(utils_id, PureItem::Mod(utils_mod));
3263
3264        // Register struct in utils
3265        let config_id = symbol_registry
3266            .register(make_path("my_crate::utils::Config"), SymbolKind::Struct)
3267            .unwrap();
3268        ast_registry.set(config_id, make_struct("Config"));
3269
3270        // Generate multi-file
3271        let generator = RegistryGenerator::multi_file();
3272        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
3273
3274        let crate_data = workspace.crates.get("my_crate").unwrap();
3275
3276        // lib.rs should have no file-level attrs
3277        let lib = crate_data.files.get("src/lib.rs").unwrap();
3278        assert_eq!(
3279            lib.pure_file.attrs.len(),
3280            0,
3281            "lib.rs should have no attrs, got: {:?}",
3282            lib.pure_file.attrs
3283        );
3284
3285        // mod utils declaration should have no attrs
3286        let utils_mod_decl = lib
3287            .pure_file
3288            .items
3289            .iter()
3290            .find_map(|item| {
3291                if let PureItem::Mod(m) = item {
3292                    if m.name == "utils" {
3293                        return Some(m);
3294                    }
3295                }
3296                None
3297            })
3298            .expect("Should have mod utils");
3299
3300        assert_eq!(
3301            utils_mod_decl.attrs.len(),
3302            0,
3303            "mod utils declaration should have no attrs, got: {:?}",
3304            utils_mod_decl.attrs
3305        );
3306
3307        // utils.rs should have no file-level attrs
3308        let utils_file = crate_data.files.get("src/utils.rs").unwrap();
3309        assert_eq!(
3310            utils_file.pure_file.attrs.len(),
3311            0,
3312            "utils.rs should have no attrs, got: {:?}",
3313            utils_file.pure_file.attrs
3314        );
3315    }
3316
3317    /// Test: External module with non-empty items should NOT be inlined
3318    ///
3319    /// This reproduces the bug where external modules (like filter.rs) get inlined
3320    /// into lib.rs because their PureMod.items is non-empty after parsing.
3321    ///
3322    /// The correct behavior: Only modules marked via `ast_registry.mark_inline_module()`
3323    /// should be inlined. External modules (not marked) should generate separate files,
3324    /// regardless of whether their PureMod.items is empty or not.
3325    #[test]
3326    fn test_external_module_with_items_not_inlined() {
3327        let mut ast_registry = ASTRegistry::new();
3328        let mut symbol_registry = SymbolRegistry::new();
3329
3330        // Register crate root
3331        symbol_registry
3332            .register(make_path("my_crate"), SymbolKind::Mod)
3333            .unwrap();
3334
3335        // Register a struct at crate root
3336        let config_id = symbol_registry
3337            .register(make_path("my_crate::Config"), SymbolKind::Struct)
3338            .unwrap();
3339        ast_registry.set(config_id, make_struct("Config"));
3340
3341        // Simulate external module "filter" that was parsed from filter.rs
3342        // Key: PureMod.items is NON-EMPTY (contains the Filter enum)
3343        // But it's NOT marked as inline via mark_inline_module()
3344        let filter_mod = PureMod {
3345            attrs: vec![],
3346            vis: PureVis::Public,
3347            name: "filter".to_string(),
3348            items: vec![
3349                // Filter enum inside the module
3350                PureItem::Enum(PureEnum {
3351                    attrs: vec![],
3352                    vis: PureVis::Public,
3353                    name: "Filter".to_string(),
3354                    generics: PureGenerics::default(),
3355                    variants: vec![
3356                        PureVariant {
3357                            attrs: vec![],
3358                            name: "Identity".to_string(),
3359                            fields: PureFields::Unit,
3360                            discriminant: None,
3361                        },
3362                        PureVariant {
3363                            attrs: vec![],
3364                            name: "Field".to_string(),
3365                            fields: PureFields::Tuple(vec![PureType::Path("String".to_string())]),
3366                            discriminant: None,
3367                        },
3368                    ],
3369                }),
3370            ],
3371        };
3372
3373        let filter_id = symbol_registry
3374            .register(make_path("my_crate::filter"), SymbolKind::Mod)
3375            .unwrap();
3376        ast_registry.set(filter_id, PureItem::Mod(filter_mod));
3377        // NOTE: NOT calling ast_registry.mark_inline_module(filter_id)
3378        // This simulates an external module parsed from filter.rs
3379
3380        // Also register the Filter enum separately (as it would be in symbol_registry)
3381        let filter_enum_id = symbol_registry
3382            .register(make_path("my_crate::filter::Filter"), SymbolKind::Enum)
3383            .unwrap();
3384        ast_registry.set(
3385            filter_enum_id,
3386            PureItem::Enum(PureEnum {
3387                attrs: vec![],
3388                vis: PureVis::Public,
3389                name: "Filter".to_string(),
3390                generics: PureGenerics::default(),
3391                variants: vec![
3392                    PureVariant {
3393                        attrs: vec![],
3394                        name: "Identity".to_string(),
3395                        fields: PureFields::Unit,
3396                        discriminant: None,
3397                    },
3398                    PureVariant {
3399                        attrs: vec![],
3400                        name: "Field".to_string(),
3401                        fields: PureFields::Tuple(vec![PureType::Path("String".to_string())]),
3402                        discriminant: None,
3403                    },
3404                ],
3405            }),
3406        );
3407
3408        // Generate multi-file output
3409        let generator = RegistryGenerator::multi_file();
3410        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
3411
3412        let crate_data = workspace.crates.get("my_crate").unwrap();
3413
3414        // === Key assertions ===
3415
3416        // 1. filter.rs should exist as a separate file
3417        assert!(
3418            crate_data.files.contains_key("src/filter.rs"),
3419            "External module 'filter' should have its own file (filter.rs). Got files: {:?}",
3420            crate_data.files.keys().collect::<Vec<_>>()
3421        );
3422
3423        // 2. lib.rs should have `mod filter;` declaration (not inline)
3424        let lib = crate_data.files.get("src/lib.rs").unwrap();
3425        assert!(
3426            lib.source.contains("mod filter;"),
3427            "lib.rs should have 'mod filter;' declaration, got:\n{}",
3428            lib.source
3429        );
3430
3431        // 3. lib.rs should NOT have inline module content
3432        assert!(
3433            !lib.source.contains("mod filter {"),
3434            "lib.rs should NOT have inline 'mod filter {{...}}', got:\n{}",
3435            lib.source
3436        );
3437
3438        // 4. lib.rs should NOT contain Filter enum
3439        assert!(
3440            !lib.source.contains("enum Filter"),
3441            "lib.rs should NOT contain Filter enum (should be in filter.rs), got:\n{}",
3442            lib.source
3443        );
3444
3445        // 5. filter.rs should contain the Filter enum
3446        let filter_file = crate_data.files.get("src/filter.rs").unwrap();
3447        assert!(
3448            filter_file.source.contains("pub enum Filter"),
3449            "filter.rs should contain 'pub enum Filter', got:\n{}",
3450            filter_file.source
3451        );
3452
3453        // Verify all files are valid Rust
3454        for (path, file) in &crate_data.files {
3455            syn::parse_str::<syn::File>(&file.source)
3456                .unwrap_or_else(|_| panic!("[{}] Should be valid Rust:\n{}", path, file.source));
3457        }
3458    }
3459
3460    /// Test: Inline module (marked) should stay inline even with items
3461    ///
3462    /// Complementary test: modules marked via mark_inline_module() should
3463    /// remain inline in the parent file.
3464    #[test]
3465    fn test_marked_inline_module_stays_inline() {
3466        use ryo_source::pure::{PureBlock, PureFn};
3467
3468        let mut ast_registry = ASTRegistry::new();
3469        let mut symbol_registry = SymbolRegistry::new();
3470
3471        // Register crate root
3472        symbol_registry
3473            .register(make_path("my_crate"), SymbolKind::Mod)
3474            .unwrap();
3475
3476        // Register a struct at crate root
3477        let config_id = symbol_registry
3478            .register(make_path("my_crate::Config"), SymbolKind::Struct)
3479            .unwrap();
3480        ast_registry.set(config_id, make_struct("Config"));
3481
3482        // Inline module "tests" - marked via mark_inline_module()
3483        let tests_mod = PureMod {
3484            attrs: vec![PureAttribute {
3485                path: "cfg".to_string(),
3486                meta: PureAttrMeta::List("test".to_string()),
3487                is_inner: false,
3488            }],
3489            vis: PureVis::Private,
3490            name: "tests".to_string(),
3491            items: vec![PureItem::Fn(PureFn {
3492                attrs: vec![PureAttribute {
3493                    path: "test".to_string(),
3494                    meta: PureAttrMeta::Path,
3495                    is_inner: false,
3496                }],
3497                vis: PureVis::Private,
3498                is_async: false,
3499                is_async_inferred: false,
3500                is_const: false,
3501                is_unsafe: false,
3502                abi: None,
3503                name: "test_something".to_string(),
3504                generics: PureGenerics::default(),
3505                params: vec![],
3506                ret: None,
3507                body: PureBlock::default(),
3508            })],
3509        };
3510
3511        let tests_id = symbol_registry
3512            .register(make_path("my_crate::tests"), SymbolKind::Mod)
3513            .unwrap();
3514        ast_registry.set(tests_id, PureItem::Mod(tests_mod));
3515        // Mark as inline - this is the key difference from the previous test
3516        ast_registry.mark_inline_module(tests_id);
3517
3518        // Generate multi-file output
3519        let generator = RegistryGenerator::multi_file();
3520        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
3521
3522        let crate_data = workspace.crates.get("my_crate").unwrap();
3523
3524        // === Key assertions ===
3525
3526        // 1. tests.rs should NOT exist (inline module stays in lib.rs)
3527        assert!(
3528            !crate_data.files.contains_key("src/tests.rs"),
3529            "Inline module 'tests' should NOT have its own file. Got files: {:?}",
3530            crate_data.files.keys().collect::<Vec<_>>()
3531        );
3532
3533        // 2. lib.rs should have inline module content
3534        let lib = crate_data.files.get("src/lib.rs").unwrap();
3535        assert!(
3536            lib.source.contains("mod tests {"),
3537            "lib.rs should have inline 'mod tests {{...}}', got:\n{}",
3538            lib.source
3539        );
3540
3541        // 3. lib.rs should contain the test function
3542        assert!(
3543            lib.source.contains("fn test_something"),
3544            "lib.rs should contain test_something function, got:\n{}",
3545            lib.source
3546        );
3547
3548        // 4. lib.rs should have #[cfg(test)] attribute
3549        assert!(
3550            lib.source.contains("#[cfg(test)]"),
3551            "lib.rs should have #[cfg(test)] attribute, got:\n{}",
3552            lib.source
3553        );
3554
3555        // Verify all files are valid Rust
3556        for (path, file) in &crate_data.files {
3557            syn::parse_str::<syn::File>(&file.source)
3558                .unwrap_or_else(|_| panic!("[{}] Should be valid Rust:\n{}", path, file.source));
3559        }
3560    }
3561
3562    /// Test: External module visibility should be preserved (not inferred from children)
3563    ///
3564    /// Bug: When an external module contains public items (e.g., `pub enum Filter`),
3565    /// the module declaration was incorrectly changed from `mod filter;` to `pub mod filter;`.
3566    ///
3567    /// Expected: Module visibility should come from the original source, not be inferred
3568    /// from child item visibility.
3569    #[test]
3570    fn test_external_module_visibility_preserved() {
3571        let mut ast_registry = ASTRegistry::new();
3572        let mut symbol_registry = SymbolRegistry::new();
3573
3574        // Register crate root
3575        let crate_id = symbol_registry
3576            .register(make_path("my_crate"), SymbolKind::Mod)
3577            .unwrap();
3578        ast_registry.set(
3579            crate_id,
3580            PureItem::Mod(PureMod {
3581                attrs: vec![],
3582                vis: PureVis::Public,
3583                name: "my_crate".to_string(),
3584                items: vec![],
3585            }),
3586        );
3587
3588        // Register external module "error" with PRIVATE visibility
3589        // Even though it contains a PUBLIC enum
3590        let error_mod = PureMod {
3591            attrs: vec![],
3592            vis: PureVis::Private, // <-- PRIVATE module
3593            name: "error".to_string(),
3594            items: vec![
3595                // Public enum inside the private module
3596                PureItem::Enum(PureEnum {
3597                    attrs: vec![],
3598                    vis: PureVis::Public, // <-- PUBLIC item inside
3599                    name: "Error".to_string(),
3600                    generics: PureGenerics::default(),
3601                    variants: vec![PureVariant {
3602                        attrs: vec![],
3603                        name: "IoError".to_string(),
3604                        fields: PureFields::Unit,
3605                        discriminant: None,
3606                    }],
3607                }),
3608            ],
3609        };
3610
3611        let error_mod_id = symbol_registry
3612            .register(make_path("my_crate::error"), SymbolKind::Mod)
3613            .unwrap();
3614        ast_registry.set(error_mod_id, PureItem::Mod(error_mod));
3615        // Set visibility in SymbolRegistry (as parser would do)
3616        let _ = symbol_registry.set_visibility(error_mod_id, ryo_symbol::Visibility::Private);
3617        // NOT marking as inline - this is an external module
3618
3619        // Register the Error enum separately
3620        let error_enum_id = symbol_registry
3621            .register(make_path("my_crate::error::Error"), SymbolKind::Enum)
3622            .unwrap();
3623        ast_registry.set(
3624            error_enum_id,
3625            PureItem::Enum(PureEnum {
3626                attrs: vec![],
3627                vis: PureVis::Public,
3628                name: "Error".to_string(),
3629                generics: PureGenerics::default(),
3630                variants: vec![PureVariant {
3631                    attrs: vec![],
3632                    name: "IoError".to_string(),
3633                    fields: PureFields::Unit,
3634                    discriminant: None,
3635                }],
3636            }),
3637        );
3638
3639        // Generate with multi-file mode
3640        let generator = RegistryGenerator::multi_file();
3641        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
3642
3643        let crate_data = workspace.crates.get("my_crate").unwrap();
3644
3645        // lib.rs should have PRIVATE mod declaration: `mod error;`
3646        let lib = crate_data.files.get("src/lib.rs").unwrap();
3647
3648        // Find the mod error declaration in items
3649        let error_mod_decl = lib
3650            .pure_file
3651            .items
3652            .iter()
3653            .find_map(|item| {
3654                if let PureItem::Mod(m) = item {
3655                    if m.name == "error" {
3656                        return Some(m);
3657                    }
3658                }
3659                None
3660            })
3661            .expect("Should have mod error in lib.rs");
3662
3663        // CRITICAL: Visibility should be PRIVATE, not PUBLIC
3664        assert!(
3665            matches!(error_mod_decl.vis, PureVis::Private),
3666            "mod error should be PRIVATE (original visibility), but got: {:?}\nlib.rs:\n{}",
3667            error_mod_decl.vis,
3668            lib.source
3669        );
3670
3671        // Verify source contains `mod error;` not `pub mod error;`
3672        assert!(
3673            lib.source.contains("mod error;") && !lib.source.contains("pub mod error;"),
3674            "lib.rs should have 'mod error;' (private), not 'pub mod error;'\nGot:\n{}",
3675            lib.source
3676        );
3677    }
3678
3679    /// Test: External module visibility should use PureMod.vis when SymbolRegistry has no visibility
3680    ///
3681    /// This test reproduces the actual bug: SymbolRegistry.visibility() returns None,
3682    /// and the code falls back to inferring visibility from child items.
3683    ///
3684    /// Expected: Should use PureMod.vis from ASTRegistry, not infer from children.
3685    #[test]
3686    fn test_external_module_visibility_from_pure_mod() {
3687        let mut ast_registry = ASTRegistry::new();
3688        let mut symbol_registry = SymbolRegistry::new();
3689
3690        // Register crate root
3691        let crate_id = symbol_registry
3692            .register(make_path("my_crate"), SymbolKind::Mod)
3693            .unwrap();
3694        ast_registry.set(
3695            crate_id,
3696            PureItem::Mod(PureMod {
3697                attrs: vec![],
3698                vis: PureVis::Public,
3699                name: "my_crate".to_string(),
3700                items: vec![],
3701            }),
3702        );
3703
3704        // Register external module "error" with PRIVATE visibility in PureMod
3705        // But do NOT call symbol_registry.set_visibility() (simulating parser behavior)
3706        let error_mod = PureMod {
3707            attrs: vec![],
3708            vis: PureVis::Private, // <-- PRIVATE in PureMod
3709            name: "error".to_string(),
3710            items: vec![
3711                // Public enum inside the private module
3712                PureItem::Enum(PureEnum {
3713                    attrs: vec![],
3714                    vis: PureVis::Public, // <-- PUBLIC item inside
3715                    name: "Error".to_string(),
3716                    generics: PureGenerics::default(),
3717                    variants: vec![PureVariant {
3718                        attrs: vec![],
3719                        name: "IoError".to_string(),
3720                        fields: PureFields::Unit,
3721                        discriminant: None,
3722                    }],
3723                }),
3724            ],
3725        };
3726
3727        let error_mod_id = symbol_registry
3728            .register(make_path("my_crate::error"), SymbolKind::Mod)
3729            .unwrap();
3730        ast_registry.set(error_mod_id, PureItem::Mod(error_mod));
3731        // NOTE: NOT calling symbol_registry.set_visibility() - this is the bug scenario
3732        // NOT marking as inline - this is an external module
3733
3734        // Register the Error enum separately
3735        let error_enum_id = symbol_registry
3736            .register(make_path("my_crate::error::Error"), SymbolKind::Enum)
3737            .unwrap();
3738        ast_registry.set(
3739            error_enum_id,
3740            PureItem::Enum(PureEnum {
3741                attrs: vec![],
3742                vis: PureVis::Public,
3743                name: "Error".to_string(),
3744                generics: PureGenerics::default(),
3745                variants: vec![PureVariant {
3746                    attrs: vec![],
3747                    name: "IoError".to_string(),
3748                    fields: PureFields::Unit,
3749                    discriminant: None,
3750                }],
3751            }),
3752        );
3753
3754        // Generate with multi-file mode
3755        let generator = RegistryGenerator::multi_file();
3756        let workspace = generator.generate(&ast_registry, &symbol_registry).unwrap();
3757
3758        let crate_data = workspace.crates.get("my_crate").unwrap();
3759
3760        // lib.rs should have PRIVATE mod declaration: `mod error;`
3761        let lib = crate_data.files.get("src/lib.rs").unwrap();
3762
3763        // Find the mod error declaration in items
3764        let error_mod_decl = lib
3765            .pure_file
3766            .items
3767            .iter()
3768            .find_map(|item| {
3769                if let PureItem::Mod(m) = item {
3770                    if m.name == "error" {
3771                        return Some(m);
3772                    }
3773                }
3774                None
3775            })
3776            .expect("Should have mod error in lib.rs");
3777
3778        // CRITICAL: Visibility should be PRIVATE (from PureMod.vis), not PUBLIC (inferred from children)
3779        assert!(
3780            matches!(error_mod_decl.vis, PureVis::Private),
3781            "mod error should be PRIVATE (from PureMod.vis), but got: {:?}\n\
3782             This indicates visibility is being inferred from child items instead of PureMod.vis\n\
3783             lib.rs:\n{}",
3784            error_mod_decl.vis,
3785            lib.source
3786        );
3787    }
3788
3789    /// Test: External module visibility should be preserved in generate_with_graph path
3790    ///
3791    /// Bug: generate_file_from_graph infers visibility from children (has_public check)
3792    /// instead of using the original PureMod.vis.
3793    ///
3794    /// This test uses generate_with_graph which calls generate_file_from_graph.
3795    #[test]
3796    fn test_external_module_visibility_with_graph() {
3797        use ryo_analysis::{CodeEdgeV2, CodeGraphV2};
3798
3799        let mut ast_registry = ASTRegistry::new();
3800        let mut symbol_registry = SymbolRegistry::new();
3801        let mut code_graph = CodeGraphV2::new();
3802
3803        // Register crate root
3804        let crate_id = symbol_registry
3805            .register(make_path("my_crate"), SymbolKind::Mod)
3806            .unwrap();
3807        ast_registry.set(
3808            crate_id,
3809            PureItem::Mod(PureMod {
3810                attrs: vec![],
3811                vis: PureVis::Public,
3812                name: "my_crate".to_string(),
3813                items: vec![],
3814            }),
3815        );
3816        code_graph.add_node(crate_id);
3817        code_graph.add_crate_root(crate_id);
3818
3819        // Register external module "error" with PRIVATE visibility in PureMod
3820        let error_mod = PureMod {
3821            attrs: vec![],
3822            vis: PureVis::Private, // <-- PRIVATE module
3823            name: "error".to_string(),
3824            items: vec![], // Empty items = external module (file-based)
3825        };
3826
3827        let error_mod_id = symbol_registry
3828            .register(make_path("my_crate::error"), SymbolKind::Mod)
3829            .unwrap();
3830        ast_registry.set(error_mod_id, PureItem::Mod(error_mod));
3831        code_graph.add_node(error_mod_id);
3832        code_graph.add_edge(crate_id, error_mod_id, CodeEdgeV2::Contains);
3833
3834        // Register PUBLIC Error enum inside the PRIVATE module
3835        let error_enum_id = symbol_registry
3836            .register(make_path("my_crate::error::Error"), SymbolKind::Enum)
3837            .unwrap();
3838        ast_registry.set(
3839            error_enum_id,
3840            PureItem::Enum(PureEnum {
3841                attrs: vec![],
3842                vis: PureVis::Public, // <-- PUBLIC item inside PRIVATE module
3843                name: "Error".to_string(),
3844                generics: PureGenerics::default(),
3845                variants: vec![PureVariant {
3846                    attrs: vec![],
3847                    name: "IoError".to_string(),
3848                    fields: PureFields::Unit,
3849                    discriminant: None,
3850                }],
3851            }),
3852        );
3853        code_graph.add_node(error_enum_id);
3854        code_graph.add_edge(error_mod_id, error_enum_id, CodeEdgeV2::Contains);
3855
3856        // Generate using generate_with_graph (the actual code path used by ryo)
3857        let generator = RegistryGenerator::multi_file();
3858        let workspace = generator
3859            .generate_with_graph(&code_graph, &ast_registry, &symbol_registry)
3860            .unwrap();
3861
3862        let crate_data = workspace.crates.get("my_crate").unwrap();
3863        let lib = crate_data.files.get("src/lib.rs").unwrap();
3864
3865        // Find the mod error declaration
3866        let error_mod_decl = lib
3867            .pure_file
3868            .items
3869            .iter()
3870            .find_map(|item| {
3871                if let PureItem::Mod(m) = item {
3872                    if m.name == "error" {
3873                        return Some(m);
3874                    }
3875                }
3876                None
3877            })
3878            .expect("Should have mod error in lib.rs");
3879
3880        // CRITICAL: Visibility should be PRIVATE (from PureMod.vis), not PUBLIC (inferred from children)
3881        assert!(
3882            matches!(error_mod_decl.vis, PureVis::Private),
3883            "mod error should be PRIVATE (from PureMod.vis), but got: {:?}\n\
3884             Bug: generate_file_from_graph is inferring visibility from children instead of PureMod.vis\n\
3885             lib.rs:\n{}",
3886            error_mod_decl.vis,
3887            lib.source
3888        );
3889    }
3890
3891    // ========================================================================
3892    // generate_affected / symbol_path_to_file_key tests
3893    // ========================================================================
3894
3895    #[test]
3896    fn test_symbol_path_to_file_key_root_items() {
3897        let gen = RegistryGenerator::multi_file();
3898
3899        // crate::Config → src/lib.rs
3900        let path = SymbolPath::parse("my_crate::Config").unwrap();
3901        assert_eq!(gen.symbol_path_to_file_key(&path), "src/lib.rs");
3902
3903        // crate (root module itself) → src/lib.rs
3904        let path = SymbolPath::parse("my_crate").unwrap();
3905        assert_eq!(gen.symbol_path_to_file_key(&path), "src/lib.rs");
3906    }
3907
3908    #[test]
3909    fn test_symbol_path_to_file_key_module_items() {
3910        let gen = RegistryGenerator::multi_file();
3911
3912        // crate::models::User → src/models.rs
3913        let path = SymbolPath::parse("my_crate::models::User").unwrap();
3914        assert_eq!(gen.symbol_path_to_file_key(&path), "src/models.rs");
3915    }
3916
3917    #[test]
3918    fn test_symbol_path_to_file_key_nested_modules() {
3919        let gen = RegistryGenerator::multi_file();
3920
3921        // crate::models::sub::Foo → src/models/sub.rs
3922        let path = SymbolPath::parse("my_crate::models::sub::Foo").unwrap();
3923        assert_eq!(gen.symbol_path_to_file_key(&path), "src/models/sub.rs");
3924    }
3925
3926    #[test]
3927    fn test_symbol_path_to_file_key_main_symbol() {
3928        let gen = RegistryGenerator::multi_file();
3929
3930        // main::my_app::Config → src/main.rs
3931        let path = SymbolPath::parse("main::my_app::Config").unwrap();
3932        assert_eq!(gen.symbol_path_to_file_key(&path), "src/main.rs");
3933
3934        // main::my_app::cli::Args → src/cli.rs
3935        let path = SymbolPath::parse("main::my_app::cli::Args").unwrap();
3936        assert_eq!(gen.symbol_path_to_file_key(&path), "src/cli.rs");
3937    }
3938
3939    #[test]
3940    fn test_generate_affected_empty_symbols_returns_empty() {
3941        let ast_registry = ASTRegistry::new();
3942        let symbol_registry = SymbolRegistry::new();
3943        let gen = RegistryGenerator::multi_file();
3944
3945        // Empty affected set → no files generated
3946        let workspace = gen
3947            .generate_internal(&ast_registry, &symbol_registry, Some(&HashSet::new()))
3948            .unwrap();
3949        assert_eq!(workspace.total_files(), 0);
3950    }
3951
3952    #[test]
3953    fn test_generate_affected_only_affected_files() {
3954        // Setup: two modules (models and handlers), modify only models
3955        let mut ast_registry = ASTRegistry::new();
3956        let mut symbol_registry = SymbolRegistry::new();
3957
3958        // Register crate root
3959        let root_id = symbol_registry
3960            .register(SymbolPath::parse("test_crate").unwrap(), SymbolKind::Mod)
3961            .unwrap();
3962
3963        // Register models module and item
3964        let models_mod_id = symbol_registry
3965            .register(
3966                SymbolPath::parse("test_crate::models").unwrap(),
3967                SymbolKind::Mod,
3968            )
3969            .unwrap();
3970        let user_id = symbol_registry
3971            .register(
3972                SymbolPath::parse("test_crate::models::User").unwrap(),
3973                SymbolKind::Struct,
3974            )
3975            .unwrap();
3976
3977        // Register handlers module and item
3978        let handlers_mod_id = symbol_registry
3979            .register(
3980                SymbolPath::parse("test_crate::handlers").unwrap(),
3981                SymbolKind::Mod,
3982            )
3983            .unwrap();
3984        let process_id = symbol_registry
3985            .register(
3986                SymbolPath::parse("test_crate::handlers::process").unwrap(),
3987                SymbolKind::Function,
3988            )
3989            .unwrap();
3990
3991        // Add AST entries
3992        ast_registry.set(root_id, make_mod("test_crate"));
3993        ast_registry.set(models_mod_id, make_mod("models"));
3994        ast_registry.set(user_id, make_struct("User"));
3995        ast_registry.set(handlers_mod_id, make_mod("handlers"));
3996        ast_registry.set(process_id, make_fn("process"));
3997
3998        let gen = RegistryGenerator::multi_file();
3999
4000        // Full generation should produce 3 files
4001        let full = gen.generate(&ast_registry, &symbol_registry).unwrap();
4002        let full_crate = full.crates.get("test_crate").unwrap();
4003        assert!(
4004            full_crate.files.len() >= 3,
4005            "Full gen should have lib.rs, models.rs, handlers.rs but got: {:?}",
4006            full_crate.files.keys().collect::<Vec<_>>()
4007        );
4008
4009        // Affected generation with only User modified → only src/models.rs
4010        let workspace = gen
4011            .generate_internal(
4012                &ast_registry,
4013                &symbol_registry,
4014                Some(&HashSet::from(["src/models.rs".to_string()])),
4015            )
4016            .unwrap();
4017        let affected_crate = workspace.crates.get("test_crate").unwrap();
4018        assert_eq!(
4019            affected_crate.files.len(),
4020            1,
4021            "Affected gen should only have models.rs but got: {:?}",
4022            affected_crate.files.keys().collect::<Vec<_>>()
4023        );
4024        assert!(affected_crate.files.contains_key("src/models.rs"));
4025        assert!(!affected_crate.files.contains_key("src/lib.rs"));
4026        assert!(!affected_crate.files.contains_key("src/handlers.rs"));
4027    }
4028
4029    fn make_mod(name: &str) -> PureItem {
4030        PureItem::Mod(PureMod {
4031            attrs: vec![],
4032            vis: PureVis::Public,
4033            name: name.to_string(),
4034            items: vec![],
4035        })
4036    }
4037}