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