Skip to main content

buffa_codegen/
context.rs

1//! Code generation context and descriptor-to-Rust mapping state.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::features::{self, ResolvedFeatures};
6use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
7use crate::oneof::to_snake_case;
8use crate::CodeGenConfig;
9
10/// The single reserved module name under which all ancillary generated types
11/// (views, oneof enums, extensions, `register_types`) live.
12///
13/// See `DESIGN.md` → "Generated code layout" for the full layout. The name
14/// is checked against proto package segments and message-module names by
15/// `validate_file`; a collision is a hard error.
16pub const SENTINEL_MOD: &str = "__buffa";
17
18/// A Rust type path split at the target-package boundary.
19///
20/// Returned by [`CodeGenContext::rust_type_relative_split`]. The full owned
21/// path is `to_package + within_package` (concatenated with `::`); ancillary
22/// kinds insert their `__buffa::<kind>::` prefix between the two halves.
23#[derive(Debug, Clone)]
24pub struct SplitPath {
25    /// Path from the current emission scope to the **target package root**.
26    ///
27    /// One of:
28    /// - empty (same package, nesting 0)
29    /// - `"super::super"` (same package, nesting > 0)
30    /// - `"super::…::other_pkg"` (cross-package local)
31    /// - `"::extern_crate::pkg"` (extern type — absolute, nesting-independent)
32    pub to_package: String,
33    /// Path from the target package root to the type itself
34    /// (e.g. `"Foo"` or `"outer::Inner"`).
35    pub within_package: String,
36    /// `true` when `to_package` is an absolute (`::`/`crate::`) extern path.
37    /// Extern paths don't depend on the caller's nesting depth.
38    pub is_extern: bool,
39}
40
41/// Shared context for a code generation run.
42///
43/// Holds the full set of file descriptors and a mapping from fully-qualified
44/// protobuf type names to their Rust type paths. This is needed because a
45/// field in one `.proto` file may reference a message defined in another.
46pub struct CodeGenContext<'a> {
47    /// All file descriptors (both requested and dependencies).
48    pub files: &'a [FileDescriptorProto],
49    /// Code generation configuration.
50    pub config: &'a CodeGenConfig,
51    /// Map from fully-qualified protobuf name (e.g., ".my.package.MyMessage")
52    /// to Rust type path (e.g., "my::package::MyMessage").
53    ///
54    /// Nested types use module-qualified paths:
55    /// ".pkg.Outer.Inner" → "pkg::outer::Inner" (not "pkg::OuterInner").
56    pub type_map: HashMap<String, String>,
57    /// Map from fully-qualified protobuf name to its proto package.
58    ///
59    /// Used by `rust_type_relative` to compute `super::`-based relative
60    /// paths for cross-package references within the same compilation.
61    package_of: HashMap<String, String>,
62    /// Map from fully-qualified enum name to its resolved `enum_type` feature.
63    ///
64    /// The `enum_type` feature determines whether an enum is OPEN or CLOSED.
65    /// It's resolved from the ENUM's own file → message → enum feature chain,
66    /// NOT from the referencing field's chain. protoc does not propagate
67    /// enum-level `enum_type` into field options (verified 2026-03), so
68    /// callers must look this up via `is_enum_closed`.
69    enum_closedness: HashMap<String, bool>,
70    /// Map from fully-qualified protobuf element name to its source comment.
71    ///
72    /// Keys use dotted FQN form without a leading dot, matching the `proto_fqn`
73    /// values already threaded through codegen: `"pkg.Message"`,
74    /// `"pkg.Message.field_name"`, `"pkg.Enum.VALUE_NAME"`,
75    /// `"pkg.Message.oneof_name"`.
76    ///
77    /// Built by walking each file's descriptor tree alongside its
78    /// `SourceCodeInfo` (which uses index-based paths). This up-front
79    /// translation means codegen call sites can look up comments by the
80    /// proto FQN they already have, rather than threading index-based paths
81    /// through every function signature.
82    comment_map: HashMap<String, String>,
83    /// Deconflicted module name for each top-level message, keyed by the
84    /// leading-dot FQN (`".pkg.Msg"`).
85    ///
86    /// A message's nested types live in a `snake_case(Name)` submodule. When
87    /// that name would collide with a sub-package module in the same scope
88    /// (proto is case-sensitive, so `message Oof` and `package foo.oof` both map
89    /// to `mod oof`), a trailing `_` is appended until the name is unique within
90    /// the scope's occupied set. Entries exist for every top-level message; the
91    /// value equals `snake_case(Name)` when no deconfliction was needed.
92    nested_module_names: HashMap<String, String>,
93    /// Variant paths (leading-dot form) resolved from
94    /// `config.unboxed_oneof_fields` whose oneof variants are stored inline.
95    /// Built once by [`resolve_unboxed_variants`](crate::oneof::resolve_unboxed_variants);
96    /// never contains recursive variants. See [`oneof_unboxed`](Self::oneof_unboxed).
97    unboxed_oneof_variants: HashSet<String>,
98    /// Non-fatal diagnostics accumulated during generation (e.g. an enum whose
99    /// idiomatic CamelCase aliases were suppressed by a naming conflict).
100    ///
101    /// Interior-mutable so deeply-nested codegen helpers can record a warning
102    /// through the shared `&CodeGenContext` without threading a sink through
103    /// every signature. Drained by [`generate_with_diagnostics`] after
104    /// generation; callers surface them as build warnings. The `RefCell` commits
105    /// the context to single-threaded use; switch to a `Mutex` if package
106    /// generation is ever parallelized.
107    ///
108    /// [`generate_with_diagnostics`]: crate::generate_with_diagnostics
109    warnings: std::cell::RefCell<Vec<crate::CodeGenWarning>>,
110    /// Package-root import phase for `CodeGenConfig::idiomatic_imports`
111    /// (file_per_package mode only). [`ImportsPhase::Off`] outside the
112    /// two-pass window, so every path resolver below is a no-op by default.
113    ///
114    /// Interior-mutable for the same reason as `warnings`: the leaf path
115    /// resolvers ([`rust_type_relative`](Self::rust_type_relative),
116    /// [`root_runtime_path`](Self::root_runtime_path)) participate through
117    /// the shared `&CodeGenContext` without threading a registry through
118    /// every emission signature.
119    ///
120    /// [`ImportsPhase::Off`]: crate::imports::ImportsPhase::Off
121    imports: std::cell::RefCell<crate::imports::ImportsPhase>,
122}
123
124/// The immediate child-package segment names directly under `package`.
125///
126/// For `package` `"foo"` and known packages `{foo, foo.oof, foo.bar.baz}`, this
127/// is `{"oof", "bar"}` — the first segment after the `"foo."` prefix of each
128/// deeper package. These are exactly the sub-package module names that will be
129/// siblings of `foo`'s message-nesting modules.
130fn child_package_segments(package: &str, all_packages: &HashSet<String>) -> HashSet<String> {
131    let prefix = if package.is_empty() {
132        String::new()
133    } else {
134        format!("{package}.")
135    };
136    all_packages
137        .iter()
138        .filter_map(|p| {
139            let rest = if package.is_empty() {
140                Some(p.as_str())
141            } else {
142                p.strip_prefix(&prefix)
143            };
144            rest.filter(|r| !r.is_empty())
145                .map(|r| r.split('.').next().unwrap_or(r).to_string())
146        })
147        .collect()
148}
149
150/// Deconflict the nested-types module names for one package's top-level
151/// messages against the sub-package modules in the same scope (issue #135).
152///
153/// `message_names` are the package's top-level message names in declaration
154/// order; `children` are the sub-package segment names that share the package's
155/// module scope. Returns one module name per input message, in the same order.
156///
157/// Each name is `snake_case(Name)` unless it collides with a sub-package
158/// segment, in which case `_` is appended until the candidate is unique against:
159/// the sub-package segments, every message's raw module name, the `__buffa`
160/// sentinel, **and every already-assigned deconflicted name**. That last set —
161/// threaded through the shared `taken` set — is what keeps two messages that
162/// would otherwise race to the same slot distinct (e.g. `Oof` and `Oof_`
163/// alongside sub-packages `oof` and `oof_` resolve to `oof__` and `oof___`,
164/// never both to `oof__`).
165///
166/// Colliding messages are assigned in a stable order (sorted by base name), so
167/// the per-message result is independent of declaration order — reordering the
168/// input files or messages never changes which name a given message receives.
169fn deconflict_package_modules(message_names: &[String], children: &HashSet<String>) -> Vec<String> {
170    let bases: Vec<String> = message_names.iter().map(|n| to_snake_case(n)).collect();
171    // Seed with everything fixed: sub-package segments, the sentinel, and every
172    // message's raw module name. Assigned deconflicted names are added as we go.
173    let mut taken: HashSet<String> = children.clone();
174    taken.insert(SENTINEL_MOD.to_string());
175    taken.extend(bases.iter().cloned());
176
177    // Result starts as the raw bases (correct for every non-colliding message),
178    // and colliding messages overwrite their slot. Assign in a stable order
179    // (sorted by base name) so the per-message suffix is independent of
180    // declaration order; two colliding messages can't both grab the same slot.
181    let mut out = bases.clone();
182    let mut order: Vec<usize> = (0..bases.len()).collect();
183    order.sort_by(|&a, &b| bases[a].cmp(&bases[b]));
184    for i in order {
185        if !children.contains(&bases[i]) {
186            continue;
187        }
188        let mut candidate = format!("{}_", bases[i]);
189        while taken.contains(&candidate) {
190            candidate.push('_');
191        }
192        taken.insert(candidate.clone());
193        out[i] = candidate;
194    }
195    out
196}
197
198impl<'a> CodeGenContext<'a> {
199    /// Build a context from file descriptors, populating the type map.
200    ///
201    /// `effective_extern_paths` includes both user-provided mappings and any
202    /// auto-injected defaults (e.g., the WKT mapping). These are computed by
203    /// `crate::effective_extern_paths` before calling this constructor.
204    ///
205    /// File-level extern resolution (currently only `descriptor.proto` /
206    /// `compiler/plugin.proto` → `buffa-descriptor`) is **not** applied by
207    /// this constructor — use [`for_generate`](Self::for_generate), which
208    /// computes and passes the file-level mappings, when generating code
209    /// that may reference descriptor types.
210    pub fn new(
211        files: &'a [FileDescriptorProto],
212        config: &'a CodeGenConfig,
213        effective_extern_paths: &[(String, String)],
214    ) -> Self {
215        Self::with_extern_resolution(files, config, effective_extern_paths, &[])
216    }
217
218    /// Build a context with both package-level and file-level extern
219    /// mappings.
220    ///
221    /// `file_extern_paths` maps a `.proto` file's full path (as reported in
222    /// `FileDescriptorProto.name`, e.g. `"google/protobuf/descriptor.proto"`)
223    /// to a Rust module root. It takes priority over a package-level
224    /// `effective_extern_paths` match, which lets two files in the same proto
225    /// package (`google.protobuf`) resolve to different external crates —
226    /// `descriptor.proto` types to `buffa-descriptor`, every other
227    /// `google.protobuf` WKT to `buffa-types`. Without this split a single
228    /// package-keyed mapping would route everything to one crate or the
229    /// other.
230    ///
231    /// Per-type resolution priority (issue #111), most specific first: an
232    /// **exact** `extern_path` entry for a type's FQN, then the **file-level**
233    /// mapping above, then a **package/prefix** `extern_path` match, then the
234    /// **local** package path. See [`resolve_type_path`].
235    ///
236    /// File-level mappings are an internal mechanism used for the
237    /// auto-injected descriptor-types routing. They are not part of the
238    /// public `CodeGenConfig` API; user-facing `extern_path` entries are keyed
239    /// by proto package *or* type FQN.
240    pub(crate) fn with_extern_resolution(
241        files: &'a [FileDescriptorProto],
242        config: &'a CodeGenConfig,
243        effective_extern_paths: &[(String, String)],
244        file_extern_paths: &[(String, String)],
245    ) -> Self {
246        let mut type_map = HashMap::new();
247        let mut package_of = HashMap::new();
248        let mut enum_closedness = HashMap::new();
249        let mut comment_map = HashMap::new();
250        let mut nested_module_names = HashMap::new();
251        let unboxed_oneof_variants =
252            crate::oneof::resolve_unboxed_variants(files, &config.unboxed_oneof_fields);
253
254        // Pre-pass: collect every package and top-level message name in the
255        // descriptor set so nested-types module deconfliction (issue #135) is
256        // identical whether the package is generated locally or referenced via
257        // `extern_path`. Skipping extern packages here made cross-crate refs
258        // use plain `snake_case` (e.g. `money::Currency`) while the owning
259        // crate emits deconflicted modules (e.g. `money_::Currency`).
260        let mut all_packages: HashSet<String> = HashSet::new();
261        let mut pkg_message_names: HashMap<String, Vec<String>> = HashMap::new();
262        for file in files {
263            let package = file.package.as_deref().unwrap_or("");
264            all_packages.insert(package.to_string());
265            for msg in &file.message_type {
266                if let Some(name) = &msg.name {
267                    // A per-type `extern_path` override (issue #111) makes the
268                    // message extern: it emits no local module, so it must not
269                    // reserve a module name in sub-package deconfliction (#135).
270                    let fqn = if package.is_empty() {
271                        format!(".{name}")
272                    } else {
273                        format!(".{package}.{name}")
274                    };
275                    if effective_extern_paths
276                        .iter()
277                        .any(|(proto, _)| proto == &fqn)
278                    {
279                        continue;
280                    }
281                    pkg_message_names
282                        .entry(package.to_string())
283                        .or_default()
284                        .push(name.clone());
285                }
286            }
287        }
288
289        // Resolve the deconflicted nested-types module name for every top-level
290        // message, batched per package so racing deconflictions stay distinct.
291        // Each package is an independent scope, so the populated map is the same
292        // regardless of `pkg_message_names` iteration order.
293        for (package, names) in &pkg_message_names {
294            let children = child_package_segments(package, &all_packages);
295            let modules = deconflict_package_modules(names, &children);
296            for (name, module) in names.iter().zip(modules) {
297                let fqn = if package.is_empty() {
298                    format!(".{name}")
299                } else {
300                    format!(".{package}.{name}")
301                };
302                nested_module_names.insert(fqn, module);
303            }
304        }
305
306        for file in files {
307            comment_map.extend(crate::comments::fqn_comments(file));
308            let package = file.package.as_deref().unwrap_or("");
309            let file_features = features::for_file(file);
310            let proto_prefix = if package.is_empty() {
311                String::from(".")
312            } else {
313                format!(".{}.", package)
314            };
315
316            // The file-level extern root (the internal descriptor.proto →
317            // buffa-descriptor split) and the local package module. Per-type
318            // resolution (issue #111) layers exact/longest-prefix `extern_path`
319            // matching on top, via `resolve_type_path`, which preserves the
320            // historic priority: per-type exact → file-level → package prefix →
321            // local. The file-level check still outranks a package prefix so two
322            // files in the same proto package can route to different crates
323            // (descriptor.proto → buffa-descriptor, timestamp.proto →
324            // buffa-types).
325            let file_root = file
326                .name
327                .as_deref()
328                .and_then(|n| resolve_file_extern(n, file_extern_paths));
329            let local_module = package.replace('.', "::");
330
331            // Register top-level messages
332            for msg in &file.message_type {
333                if let Some(name) = &msg.name {
334                    let fqn = format!("{}{}", proto_prefix, name);
335                    let (rust_path, is_extern) = resolve_type_path(
336                        &fqn,
337                        name,
338                        file_root,
339                        &local_module,
340                        effective_extern_paths,
341                        &config.type_name_prefix,
342                    );
343
344                    // The module the message's nested types live in. For a local
345                    // message it is `<package>::<module>`, where the module name
346                    // is deconflicted against sub-package modules (issue #135),
347                    // precomputed above and looked up here so emission and
348                    // references share the same value. For an extern/overridden
349                    // message no local module is emitted, so the nested module is
350                    // the resolved path's parent plus the plain `snake_case` name.
351                    let parent_mod = if is_extern {
352                        let snake = nested_module_names
353                            .get(&fqn)
354                            .cloned()
355                            .unwrap_or_else(|| to_snake_case(name));
356                        match rust_path.rsplit_once("::") {
357                            Some((parent, _)) => format!("{parent}::{snake}"),
358                            None => snake,
359                        }
360                    } else {
361                        let snake = nested_module_names
362                            .get(&fqn)
363                            .cloned()
364                            .unwrap_or_else(|| to_snake_case(name));
365                        join_mod(&local_module, &snake)
366                    };
367
368                    type_map.insert(fqn.clone(), rust_path);
369                    package_of.insert(fqn.clone(), package.to_string());
370                    register_nested_types(
371                        &mut type_map,
372                        &mut package_of,
373                        NestedRegistrationCtx {
374                            package,
375                            extern_paths: effective_extern_paths,
376                            type_name_prefix: &config.type_name_prefix,
377                        },
378                        &fqn,
379                        &parent_mod,
380                        msg,
381                    );
382                    register_nested_enum_closedness(
383                        &mut enum_closedness,
384                        &fqn,
385                        &file_features,
386                        msg,
387                    );
388                }
389            }
390
391            // Register top-level enums
392            for enum_type in &file.enum_type {
393                if let Some(name) = &enum_type.name {
394                    let fqn = format!("{}{}", proto_prefix, name);
395                    let (rust_path, _) = resolve_type_path(
396                        &fqn,
397                        name,
398                        file_root,
399                        &local_module,
400                        effective_extern_paths,
401                        &config.type_name_prefix,
402                    );
403                    type_map.insert(fqn.clone(), rust_path);
404                    package_of.insert(fqn.clone(), package.to_string());
405                    register_enum_closedness(&mut enum_closedness, &fqn, &file_features, enum_type);
406                }
407            }
408        }
409
410        Self {
411            files,
412            config,
413            type_map,
414            package_of,
415            enum_closedness,
416            comment_map,
417            nested_module_names,
418            unboxed_oneof_variants,
419            warnings: std::cell::RefCell::new(Vec::new()),
420            imports: std::cell::RefCell::new(crate::imports::ImportsPhase::Off),
421        }
422    }
423
424    /// Record a non-fatal diagnostic to surface as a build warning.
425    pub(crate) fn warn(&self, warning: crate::CodeGenWarning) {
426        self.warnings.borrow_mut().push(warning);
427    }
428
429    /// Drain the diagnostics accumulated during generation.
430    ///
431    /// `pub(crate)` so it can only be called from [`generate_with_diagnostics`]
432    /// after all packages are generated — draining mid-flight would truncate the
433    /// diagnostic stream.
434    pub(crate) fn take_warnings(&self) -> Vec<crate::CodeGenWarning> {
435        self.warnings.take()
436    }
437
438    /// Truncate the warning sink back to `len` entries.
439    ///
440    /// Used by the `idiomatic_imports` collection pass, whose dry-run
441    /// generation would otherwise record every diagnostic twice.
442    pub(crate) fn truncate_warnings(&self, len: usize) {
443        self.warnings.borrow_mut().truncate(len);
444    }
445
446    /// The number of warnings recorded so far (a mark for
447    /// [`truncate_warnings`](Self::truncate_warnings)).
448    pub(crate) fn warnings_len(&self) -> usize {
449        self.warnings.borrow().len()
450    }
451
452    // ── Package-root import registry (idiomatic_imports) ────────────────
453
454    /// Enter the collection phase: subsequent package-root path
455    /// resolutions are recorded instead of shortened.
456    pub(crate) fn imports_begin_collecting(&self) {
457        *self.imports.borrow_mut() =
458            crate::imports::ImportsPhase::Collecting(std::collections::BTreeSet::new());
459    }
460
461    /// Leave the collection phase, returning the recorded paths.
462    pub(crate) fn imports_take_collected(&self) -> std::collections::BTreeSet<String> {
463        match self.imports.replace(crate::imports::ImportsPhase::Off) {
464            crate::imports::ImportsPhase::Collecting(set) => set,
465            _ => std::collections::BTreeSet::new(),
466        }
467    }
468
469    /// Enter the resolution phase with assigned bindings.
470    pub(crate) fn imports_set_resolving(&self, imports: crate::imports::RootImports) {
471        *self.imports.borrow_mut() = crate::imports::ImportsPhase::Resolving(imports);
472    }
473
474    /// Reset to [`ImportsPhase::Off`] after a package is generated, so
475    /// state never leaks into the next package.
476    ///
477    /// [`ImportsPhase::Off`]: crate::imports::ImportsPhase::Off
478    pub(crate) fn imports_reset(&self) {
479        *self.imports.borrow_mut() = crate::imports::ImportsPhase::Off;
480    }
481
482    /// The `use` block backing the current package's assigned short names
483    /// (empty unless in the resolution phase).
484    pub(crate) fn imports_use_block(&self) -> proc_macro2::TokenStream {
485        match &*self.imports.borrow() {
486            crate::imports::ImportsPhase::Resolving(imports) => imports.use_items(),
487            _ => proc_macro2::TokenStream::new(),
488        }
489    }
490
491    /// Route a package-root type path through the import registry.
492    ///
493    /// `nesting` is the emitting scope's module depth below the package
494    /// root; only depth-0 emissions land in the package-root scope where
495    /// the `use` block is visible, so anything deeper passes through
496    /// unchanged — as does everything when the registry is off.
497    fn root_path(&self, path: String, nesting: usize) -> String {
498        if nesting != 0 {
499            return path;
500        }
501        match &mut *self.imports.borrow_mut() {
502            crate::imports::ImportsPhase::Off => path,
503            crate::imports::ImportsPhase::Collecting(set) => {
504                if crate::imports::shortenable(&path) {
505                    set.insert(path.clone());
506                }
507                path
508            }
509            crate::imports::ImportsPhase::Resolving(imports) => match imports.resolve(&path) {
510                Some(short) => short.to_string(),
511                None => path,
512            },
513        }
514    }
515
516    /// Route a runtime/prelude type's canonical qualified path through the
517    /// import registry, returning the tokens to emit.
518    ///
519    /// Same depth-0 gating as [`root_path`](Self::root_path); `canonical`
520    /// must be one of the `RUNTIME_IMPORTS` paths in `imports.rs`.
521    pub(crate) fn root_runtime_path(
522        &self,
523        canonical: &str,
524        nesting: usize,
525    ) -> proc_macro2::TokenStream {
526        crate::idents::rust_path_to_tokens(&self.root_path(canonical.to_string(), nesting))
527    }
528
529    /// The nested-types module name for a top-level message, deconflicted
530    /// against sub-package modules (issue #135).
531    ///
532    /// `package` is the proto package (empty for none), `name` the message's
533    /// proto name. Returns the recorded deconflicted name (e.g. `oof_` when
534    /// `message Oof` collides with `package <pkg>.oof`), or `snake_case(name)`
535    /// when no override was recorded. Both emission and reference resolution go
536    /// through the same recorded value, so they always agree.
537    pub fn nested_module_name(&self, package: &str, name: &str) -> String {
538        let fqn = if package.is_empty() {
539            format!(".{name}")
540        } else {
541            format!(".{package}.{name}")
542        };
543        self.nested_module_names
544            .get(&fqn)
545            .cloned()
546            .unwrap_or_else(|| to_snake_case(name))
547    }
548
549    /// Build a context matching what [`generate()`](crate::generate) uses
550    /// internally.
551    ///
552    /// Computes effective extern paths (user-provided + auto-injected WKT
553    /// mapping to `buffa-types` + auto-injected `descriptor.proto` /
554    /// `compiler/plugin.proto` file-level mapping to `buffa-descriptor`) and
555    /// builds the type map from them.
556    ///
557    /// Convenience for downstream generators (e.g. `connectrpc-codegen`)
558    /// that emit code alongside buffa's message types and need identical
559    /// type-path resolution. Using this instead of [`new()`](Self::new) +
560    /// manual extern-path computation ensures zero drift with buffa's own
561    /// generation.
562    pub fn for_generate(
563        files: &'a [FileDescriptorProto],
564        files_to_generate: &[String],
565        config: &'a CodeGenConfig,
566    ) -> Self {
567        let paths = crate::effective_extern_paths(files, files_to_generate, config);
568        let file_paths = crate::effective_file_extern_paths(files_to_generate, config);
569        Self::with_extern_resolution(files, config, &paths, &file_paths)
570    }
571
572    /// Look up the Rust type path for a fully-qualified protobuf type name.
573    pub fn rust_type(&self, proto_fqn: &str) -> Option<&str> {
574        self.type_map.get(proto_fqn).map(|s| s.as_str())
575    }
576
577    /// Look up the source comment for a protobuf element by FQN.
578    ///
579    /// `fqn` uses the same dotted form as `proto_fqn` throughout codegen
580    /// (no leading dot). For sub-elements, append the element name:
581    /// - Message: `"pkg.Message"`
582    /// - Field: `"pkg.Message.field_name"`
583    /// - Enum value: `"pkg.Enum.VALUE_NAME"`
584    /// - Oneof: `"pkg.Message.oneof_name"`
585    pub fn comment(&self, fqn: &str) -> Option<&str> {
586        self.comment_map.get(fqn).map(|s| s.as_str())
587    }
588
589    /// Look up whether an enum (by fully-qualified proto name) is closed.
590    ///
591    /// Returns `None` if the enum is not in this compilation set (e.g., an
592    /// extern_path type), in which case callers should fall back to the
593    /// referencing field's feature chain (correct for proto2/proto3 where
594    /// `enum_type` is file-level anyway).
595    pub fn is_enum_closed(&self, proto_fqn: &str) -> Option<bool> {
596        self.enum_closedness.get(proto_fqn).copied()
597    }
598
599    /// Look up the Rust type path relative to the current code generation
600    /// scope.
601    ///
602    /// `current_package` is the proto package (e.g., `"google.protobuf"`).
603    /// `nesting` is the number of message module levels the generated code
604    /// sits inside (0 for struct fields and impls at the package level,
605    /// 1 for oneof enums inside a message module, etc.).
606    ///
607    /// - **Same package**: strips the package prefix and prepends `super::`
608    ///   for each nesting level.
609    /// - **Cross package (local)**: navigates via `super::` to the common
610    ///   ancestor, then descends into the target package. This works
611    ///   regardless of where the module tree is placed in the user's crate.
612    /// - **Cross package (extern)**: returns the absolute extern path as-is.
613    pub fn rust_type_relative(
614        &self,
615        proto_fqn: &str,
616        current_package: &str,
617        nesting: usize,
618    ) -> Option<String> {
619        let full_path = self.type_map.get(proto_fqn)?;
620
621        // Extern types use absolute paths (starting with `::` or `crate::`)
622        // and need no relative resolution — they work from any module position.
623        if full_path.starts_with("::") || full_path.starts_with("crate::") {
624            return Some(self.root_path(full_path.clone(), nesting));
625        }
626
627        let target_package = self
628            .package_of
629            .get(proto_fqn)
630            .map(|s| s.as_str())
631            .unwrap_or("");
632
633        // Extract the type's path within its package (everything after the
634        // package module prefix).
635        let target_rust_module = target_package.replace('.', "::");
636        let type_suffix = if target_rust_module.is_empty() {
637            full_path.as_str()
638        } else {
639            full_path
640                .strip_prefix(&format!("{}::", target_rust_module))
641                .unwrap_or(full_path)
642        };
643
644        if current_package == target_package {
645            // Same package — just the type suffix, with super:: for nesting.
646            if nesting == 0 {
647                return Some(type_suffix.to_string());
648            }
649            let supers = (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::");
650            return Some(format!("{}::{}", supers, type_suffix));
651        }
652
653        // Cross-package local type: compute a super::-based relative path.
654        let current_parts: Vec<&str> = if current_package.is_empty() {
655            vec![]
656        } else {
657            current_package.split('.').collect()
658        };
659        let target_parts: Vec<&str> = if target_package.is_empty() {
660            vec![]
661        } else {
662            target_package.split('.').collect()
663        };
664
665        // Find the length of the common package prefix.
666        let common_len = current_parts
667            .iter()
668            .zip(&target_parts)
669            .take_while(|(a, b)| a == b)
670            .count();
671
672        // Navigate up: one super:: per remaining current package segment,
673        // plus one per nesting level (message module depth).
674        let up_count = (current_parts.len() - common_len) + nesting;
675
676        // Navigate down: target package segments beyond the common prefix.
677        let down_parts = &target_parts[common_len..];
678
679        let mut segments: Vec<&str> = vec!["super"; up_count];
680        segments.extend_from_slice(down_parts);
681
682        // Append the type's within-package path.
683        let mut result = segments.join("::");
684        if !result.is_empty() {
685            result.push_str("::");
686        }
687        result.push_str(type_suffix);
688
689        Some(self.root_path(result, nesting))
690    }
691
692    /// Like [`rust_type_relative`](Self::rust_type_relative) but returns the
693    /// path split at the target-package boundary.
694    ///
695    /// Ancillary kinds (views, oneof enums) live in the `__buffa::<kind>::`
696    /// sub-tree of each package; callers compose the final path as
697    /// `to_package + "::__buffa::" + <kind> + "::" + within_package`.
698    ///
699    /// `nesting` is the **total** module depth of the caller's emission
700    /// scope below the current package root — i.e. message-nesting plus any
701    /// `__buffa::<kind>::` levels the caller is already inside (0 for owned
702    /// types, +2 for `__buffa::view::`, +3 for `__buffa::view::oneof::`).
703    pub fn rust_type_relative_split(
704        &self,
705        proto_fqn: &str,
706        current_package: &str,
707        nesting: usize,
708    ) -> Option<SplitPath> {
709        let full_path = self.type_map.get(proto_fqn)?;
710
711        let target_package = self
712            .package_of
713            .get(proto_fqn)
714            .map(|s| s.as_str())
715            .unwrap_or("");
716
717        // Compute the type's path within its package (everything after the
718        // package module prefix). For extern types the prefix is the
719        // configured rust_module (e.g. `::buffa_types::google::protobuf`),
720        // not the bare dotted package, so derive it the same way `new()`
721        // populated the map.
722        let target_rust_module = if full_path.starts_with("::") || full_path.starts_with("crate::")
723        {
724            // Reconstruct the extern module prefix by stripping the
725            // within-package suffix length. We know the proto FQN's
726            // within-package portion (FQN minus package), so the full_path's
727            // last N segments correspond to it.
728            //
729            // Simpler: re-derive via `resolve_extern_prefix` would need the
730            // original extern_paths list. Instead, compute within-package
731            // from the proto FQN (which we know) and slice full_path.
732            let fqn_no_dot = proto_fqn.strip_prefix('.').unwrap_or(proto_fqn);
733            let within_proto = if target_package.is_empty() {
734                fqn_no_dot
735            } else {
736                fqn_no_dot
737                    .strip_prefix(target_package)
738                    .and_then(|s| s.strip_prefix('.'))
739                    .unwrap_or(fqn_no_dot)
740            };
741            // within_proto is dotted (e.g. "Outer.Inner"); within full_path
742            // it's `outer::Inner` (snake_case modules + final PascalCase).
743            // Count the segments and strip that many from full_path to recover
744            // the module the type lives in.
745            //
746            // For paths buffa builds itself (`<rust_module>::<within>`) and for
747            // per-type `extern_path` overrides whose target mirrors the proto
748            // nesting (the sensible case — e.g. mapping to another
749            // buffa-generated crate), `full_segs.len() >= within_segs`. A
750            // pathological override that maps a deeply-nested type to a shorter
751            // Rust path can't have a matching `__buffa::` view/oneof tree
752            // anyway, so we clamp with `saturating_sub` rather than panic and
753            // let the (unresolvable) reference surface as a normal compile error.
754            let within_segs = within_proto.split('.').count();
755            let full_segs: Vec<&str> = full_path.split("::").collect();
756            let cut = full_segs.len().saturating_sub(within_segs);
757            full_segs[..cut].join("::")
758        } else {
759            target_package.replace('.', "::")
760        };
761
762        let type_suffix = if target_rust_module.is_empty() {
763            full_path.as_str()
764        } else {
765            full_path
766                .strip_prefix(&format!("{}::", target_rust_module))
767                .unwrap_or(full_path)
768        };
769
770        // Extern: absolute path; nesting irrelevant.
771        if full_path.starts_with("::") || full_path.starts_with("crate::") {
772            return Some(SplitPath {
773                to_package: target_rust_module,
774                within_package: type_suffix.to_string(),
775                is_extern: true,
776            });
777        }
778
779        if current_package == target_package {
780            let to_package = if nesting == 0 {
781                String::new()
782            } else {
783                (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::")
784            };
785            return Some(SplitPath {
786                to_package,
787                within_package: type_suffix.to_string(),
788                is_extern: false,
789            });
790        }
791
792        // Cross-package local.
793        let current_parts: Vec<&str> = if current_package.is_empty() {
794            vec![]
795        } else {
796            current_package.split('.').collect()
797        };
798        let target_parts: Vec<&str> = if target_package.is_empty() {
799            vec![]
800        } else {
801            target_package.split('.').collect()
802        };
803        let common_len = current_parts
804            .iter()
805            .zip(&target_parts)
806            .take_while(|(a, b)| a == b)
807            .count();
808        let up_count = (current_parts.len() - common_len) + nesting;
809        let down_parts = &target_parts[common_len..];
810
811        let mut segments: Vec<&str> = vec!["super"; up_count];
812        segments.extend_from_slice(down_parts);
813
814        Some(SplitPath {
815            to_package: segments.join("::"),
816            within_package: type_suffix.to_string(),
817            is_extern: false,
818        })
819    }
820
821    /// Collect custom attributes matching a fully-qualified proto path.
822    ///
823    /// Returns a `TokenStream` of all `#[...]` attributes whose path prefix
824    /// matches `fqn`. Each attribute string is parsed via `syn::parse_str`
825    /// so the caller can interpolate directly into `quote!`.
826    ///
827    /// `fqn` uses dotted form without a leading dot (e.g., `"my.pkg.MyMessage"`).
828    ///
829    /// # Errors
830    ///
831    /// Returns `CodeGenError::InvalidCustomAttribute` if any matching attribute
832    /// string fails to parse as a valid Rust attribute.
833    pub(crate) fn matching_attributes(
834        attrs: &[(String, String)],
835        fqn: &str,
836    ) -> Result<proc_macro2::TokenStream, crate::CodeGenError> {
837        if attrs.is_empty() {
838            return Ok(proc_macro2::TokenStream::new());
839        }
840        let fqn_dotted = format!(".{fqn}");
841        let mut tokens = proc_macro2::TokenStream::new();
842        for (prefix, attr_str) in attrs {
843            if matches_proto_prefix(prefix, &fqn_dotted) {
844                let parsed =
845                    syn::parse_str::<proc_macro2::TokenStream>(attr_str).map_err(|err| {
846                        crate::CodeGenError::InvalidCustomAttribute {
847                            path: prefix.clone(),
848                            attribute: attr_str.clone(),
849                            detail: err.to_string(),
850                        }
851                    })?;
852                tokens.extend(parsed);
853            }
854        }
855        Ok(tokens)
856    }
857
858    /// Resolve the [`BytesRepr`](crate::BytesRepr) for a `bytes` field at the
859    /// given proto path.
860    ///
861    /// `field_fqn` is the fully-qualified proto field path, e.g.,
862    /// `".my.pkg.MyMessage.data"`. Rules in `config.bytes_fields` are matched
863    /// using proto-segment-aware prefix matching (`"."` matches all,
864    /// `".my.pkg"` matches `".my.pkg.Msg.data"` but not `".my.pkgs.X.data"`);
865    /// the **last** matching rule wins, letting a specific override follow a
866    /// broad default. Fields matching no rule use
867    /// [`BytesRepr::Vec`](crate::BytesRepr::Vec).
868    pub fn bytes_repr(&self, field_fqn: &str) -> crate::BytesRepr {
869        self.config
870            .bytes_fields
871            .iter()
872            .rev()
873            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
874            .map_or(crate::BytesRepr::default(), |(_, repr)| repr.clone())
875    }
876
877    /// Check whether a message-typed oneof variant at the given proto path is
878    /// stored inline (opted out of `Box` wrapping).
879    ///
880    /// `variant_fqn` is the fully-qualified variant path, e.g.
881    /// `".my.pkg.MyMessage.body.small"`. This consults the set resolved at
882    /// context construction by the internal `resolve_unboxed_variants` pass,
883    /// not the raw config rules: recursive variants matched only by a prefix
884    /// rule are excluded there (they stay boxed), so every codegen site that
885    /// asks agrees with the emitted enum declaration.
886    pub fn oneof_unboxed(&self, variant_fqn: &str) -> bool {
887        self.unboxed_oneof_variants.contains(variant_fqn)
888    }
889
890    /// Resolve the [`StringRepr`](crate::StringRepr) for a `string` field at the
891    /// given proto path.
892    ///
893    /// `field_fqn` is the fully-qualified proto field path, e.g.
894    /// `".my.pkg.MyMessage.name"`. Rules in `config.string_fields` are matched
895    /// with the same proto-segment-aware prefix logic as
896    /// [`bytes_repr`](Self::bytes_repr); the **last** matching rule wins,
897    /// letting a specific override follow a broad default. Fields matching no
898    /// rule use [`StringRepr::String`](crate::StringRepr::String).
899    pub fn string_repr(&self, field_fqn: &str) -> crate::StringRepr {
900        self.config
901            .string_fields
902            .iter()
903            .rev()
904            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
905            .map_or(crate::StringRepr::default(), |(_, repr)| repr.clone())
906    }
907
908    /// Resolve the [`MapRepr`](crate::MapRepr) for a `map` field at the given
909    /// proto path.
910    ///
911    /// `field_fqn` is the fully-qualified proto field path, e.g.
912    /// `".my.pkg.MyMessage.entries"`. Rules in `config.map_fields` are matched
913    /// with the same proto-segment-aware prefix logic as
914    /// [`string_repr`](Self::string_repr); the **last** matching rule wins,
915    /// letting a specific override follow a broad default. Fields matching no
916    /// rule use [`MapRepr::HashMap`](crate::MapRepr::HashMap).
917    pub fn map_repr(&self, field_fqn: &str) -> crate::MapRepr {
918        self.config
919            .map_fields
920            .iter()
921            .rev()
922            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
923            .map_or(crate::MapRepr::default(), |(_, repr)| repr.clone())
924    }
925
926    /// Resolve the [`PointerRepr`](crate::PointerRepr) for a singular message
927    /// field at the given proto path. Last matching rule wins (proto-segment
928    /// prefix match); fields matching no rule use
929    /// [`PointerRepr::Box`](crate::PointerRepr::Box).
930    pub fn pointer_repr(&self, field_fqn: &str) -> crate::PointerRepr {
931        self.config
932            .pointer_fields
933            .iter()
934            .rev()
935            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
936            .map_or(crate::PointerRepr::default(), |(_, repr)| repr.clone())
937    }
938
939    /// Resolve the [`RepeatedRepr`](crate::RepeatedRepr) for a `repeated` field
940    /// at the given proto path.
941    ///
942    /// `field_fqn` is the fully-qualified proto field path, e.g.
943    /// `".my.pkg.MyMessage.items"`. Rules in `config.repeated_fields` are matched
944    /// with the same proto-segment-aware prefix logic as
945    /// [`string_repr`](Self::string_repr); the **last** matching rule wins,
946    /// letting a specific override follow a broad default. Fields matching no
947    /// rule use [`RepeatedRepr::Vec`](crate::RepeatedRepr::Vec).
948    pub fn repeated_repr(&self, field_fqn: &str) -> crate::RepeatedRepr {
949        self.config
950            .repeated_fields
951            .iter()
952            .rev()
953            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
954            .map_or(crate::RepeatedRepr::default(), |(_, repr)| repr.clone())
955    }
956}
957
958/// Scope-local context for code generation within a message.
959///
960/// Bundles the parameters that are constant within a single message's code
961/// generation scope and change only when recursing into nested messages.
962/// Threading this struct instead of five individual parameters keeps function
963/// signatures short and makes adding new scope-level state a one-field change.
964#[derive(Clone, Copy)]
965pub(crate) struct MessageScope<'a> {
966    /// Global codegen context (descriptors, type map, config).
967    pub ctx: &'a CodeGenContext<'a>,
968    /// Proto package of the file being generated (e.g. `"google.protobuf"`).
969    pub current_package: &'a str,
970    /// Fully-qualified proto name of the current message
971    /// (e.g. `"google.protobuf.Timestamp"`, `"pkg.Outer.Inner"`).
972    pub proto_fqn: &'a str,
973    /// Resolved edition features for this message scope.
974    pub features: &'a ResolvedFeatures,
975    /// Module nesting depth — number of `pub mod` levels the generated code
976    /// sits inside.  Controls the count of `super::` prefixes in type
977    /// references via [`CodeGenContext::rust_type_relative`].
978    pub nesting: usize,
979}
980
981impl<'a> MessageScope<'a> {
982    /// Create a child scope for a nested message (increments nesting by 1).
983    pub fn nested(&self, proto_fqn: &'a str, features: &'a ResolvedFeatures) -> MessageScope<'a> {
984        MessageScope {
985            ctx: self.ctx,
986            current_package: self.current_package,
987            proto_fqn,
988            features,
989            nesting: self.nesting + 1,
990        }
991    }
992}
993
994/// Kind of ancillary tree under the [`SENTINEL_MOD`] module.
995///
996/// `path_segments()` returns the module path *inside* `__buffa::` (not
997/// including the sentinel itself).
998#[derive(Debug, Clone, Copy, PartialEq, Eq)]
999pub(crate) enum AncillaryKind {
1000    /// `__buffa::oneof::<msg_path>::` — owned oneof enums.
1001    Oneof,
1002    /// `__buffa::view::<msg_path>::` — message view structs.
1003    View,
1004    /// `__buffa::view::oneof::<msg_path>::` — view oneof enums.
1005    ViewOneof,
1006    /// `__buffa::lazy_view::<msg_path>::` — lazy view structs.
1007    LazyView,
1008}
1009
1010impl AncillaryKind {
1011    fn path_segments(self) -> &'static [&'static str] {
1012        match self {
1013            Self::Oneof => &["oneof"],
1014            Self::View => &["view"],
1015            Self::ViewOneof => &["view", "oneof"],
1016            Self::LazyView => &["lazy_view"],
1017        }
1018    }
1019}
1020
1021/// Build a token-stream path prefix from an emission scope to an ancillary
1022/// kind's location for the **current** message (`proto_fqn`).
1023///
1024/// Always climbs to the package root via `super::` and re-descends through
1025/// `__buffa::<kind>::<msg_path>::` — uniform regardless of where the caller
1026/// sits. `from_nesting` is the caller's total module depth below the
1027/// package root (message-nesting plus any `__buffa::<kind>::` levels the
1028/// caller is already inside).
1029///
1030/// `proto_fqn` follows the dotless convention used throughout codegen
1031/// (e.g. `"google.protobuf.Value"`, not `".google.protobuf.Value"`).
1032///
1033/// Returned tokens always end with `::` so callers append the type
1034/// identifier directly: `quote! { #prefix #ident }`.
1035pub(crate) fn ancillary_prefix(
1036    kind: AncillaryKind,
1037    current_package: &str,
1038    proto_fqn: &str,
1039    from_nesting: usize,
1040) -> proc_macro2::TokenStream {
1041    use crate::idents::make_field_ident;
1042    use quote::quote;
1043
1044    debug_assert!(
1045        !proto_fqn.starts_with('.'),
1046        "ancillary_prefix expects dotless FQN, got {proto_fqn:?}"
1047    );
1048
1049    let mut supers_tokens = proc_macro2::TokenStream::new();
1050    for _ in 0..from_nesting {
1051        supers_tokens.extend(quote! { super:: });
1052    }
1053
1054    let sentinel = make_field_ident(SENTINEL_MOD);
1055    let kind_segs: Vec<_> = kind
1056        .path_segments()
1057        .iter()
1058        .map(|s| make_field_ident(s))
1059        .collect();
1060
1061    // Snake-cased message path within the package (e.g. "outer::inner::").
1062    let within_pkg = if current_package.is_empty() {
1063        proto_fqn
1064    } else {
1065        proto_fqn
1066            .strip_prefix(current_package)
1067            .and_then(|s| s.strip_prefix('.'))
1068            .unwrap_or(proto_fqn)
1069    };
1070    let msg_segs: Vec<_> = within_pkg
1071        .split('.')
1072        .filter(|s| !s.is_empty())
1073        .map(|name| make_field_ident(&to_snake_case(name)))
1074        .collect();
1075
1076    quote! { #supers_tokens #sentinel :: #(#kind_segs ::)* #(#msg_segs ::)* }
1077}
1078
1079/// Proto-segment-aware prefix match: `prefix` matches `fqn_dotted` if
1080/// `prefix == "."`, the two are equal, or `fqn_dotted` starts with `prefix`
1081/// followed by a `.` boundary. Proto identifiers are ASCII, and `.` is ASCII,
1082/// so byte indexing is safe.
1083pub(crate) fn matches_proto_prefix(prefix: &str, fqn_dotted: &str) -> bool {
1084    prefix == "."
1085        || prefix == fqn_dotted
1086        || (fqn_dotted.starts_with(prefix)
1087            && fqn_dotted.as_bytes().get(prefix.len()) == Some(&b'.'))
1088}
1089
1090/// Look up a file-level extern mapping by exact proto file path.
1091///
1092/// Unlike [`resolve_extern_prefix`], this does no prefix or package
1093/// arithmetic — file-level mappings are auto-injected for a known closed set
1094/// of bootstrap protos (`descriptor.proto`, `compiler/plugin.proto`) whose
1095/// types live in a different crate (`buffa-descriptor`) than the rest of the
1096/// `google.protobuf` package (`buffa-types`). The mapped Rust module is the
1097/// root of that file's generated module, with no further suffix.
1098fn resolve_file_extern<'p>(
1099    file_name: &str,
1100    file_extern_paths: &'p [(String, String)],
1101) -> Option<&'p str> {
1102    file_extern_paths
1103        .iter()
1104        .find(|(name, _)| name == file_name)
1105        .map(|(_, rust)| rust.as_str())
1106}
1107
1108/// Check if a proto package matches any extern_path prefix.
1109///
1110/// Returns the Rust module path root if matched, including any remaining
1111/// package segments converted to `snake_case` modules. For example,
1112/// extern_path `(".my", "::my_crate")` with package `"my.sub.pkg"` returns
1113/// `"::my_crate::sub::pkg"`.
1114///
1115/// `pub(crate)`: also called from `crate::effective_file_extern_paths` to
1116/// suppress an auto-injected file-level mapping when a user package-level
1117/// extern_path already covers that file's package.
1118pub(crate) fn resolve_extern_prefix(
1119    package: &str,
1120    extern_paths: &[(String, String)],
1121) -> Option<String> {
1122    let dotted = format!(".{}", package);
1123
1124    // Try longest prefix first so that more specific mappings take priority
1125    // over broader ones (e.g., ".my.common" before ".my").
1126    let mut best: Option<(&str, &str, usize)> = None;
1127
1128    for (proto_prefix, rust_prefix) in extern_paths {
1129        if dotted == *proto_prefix {
1130            // Exact match is always the best.
1131            return Some(rust_prefix.clone());
1132        }
1133        if let Some(rest) = dotted.strip_prefix(proto_prefix.as_str()) {
1134            // `"."` is the catch-all root; stripping it leaves no leading dot.
1135            if proto_prefix == "." || rest.starts_with('.') {
1136                let prefix_len = proto_prefix.len();
1137                // MSRV: `Option::is_none_or` requires 1.82.
1138                if best.map_or(true, |(_, _, best_len)| prefix_len > best_len) {
1139                    best = Some((proto_prefix, rust_prefix, prefix_len));
1140                }
1141            }
1142        }
1143    }
1144
1145    let (proto_prefix, rust_prefix, _) = best?;
1146    let rest = dotted.strip_prefix(proto_prefix)?;
1147    let rest = rest.strip_prefix('.').unwrap_or(rest);
1148    let suffix = rest
1149        .split('.')
1150        .map(to_snake_case)
1151        .collect::<Vec<_>>()
1152        .join("::");
1153    Some(format!("{}::{}", rust_prefix, suffix))
1154}
1155
1156/// Resolve a fully-qualified proto **type** name against `extern_paths`,
1157/// mirroring prost's `resolve_ident` (issue #111).
1158///
1159/// Unlike [`resolve_extern_prefix`] — which only matches a file's *package* and
1160/// returns the package's Rust module — this matches the type's whole FQN:
1161///
1162/// 1. An **exact** `extern_path` entry for the FQN wins (a per-type override,
1163///    e.g. `.google.protobuf.Timestamp = ::pbjson_types::Timestamp`).
1164/// 2. Otherwise the **longest dotted-prefix** entry (a package or an enclosing
1165///    type) applies, with the proto segments past that prefix rendered as
1166///    `snake_case` modules and the final segment kept as the Rust type name —
1167///    exactly the path [`CodeGenContext::new`] would otherwise build from
1168///    [`resolve_extern_prefix`] plus the type name, so package-prefix mappings
1169///    resolve identically to before.
1170///
1171/// `fqn` is the leading-dot form (e.g. `.google.protobuf.Timestamp`). Returns
1172/// the full Rust type path, or `None` when nothing matches (the caller falls
1173/// back to the local package path).
1174fn resolve_extern_type(fqn: &str, extern_paths: &[(String, String)]) -> Option<String> {
1175    // 1. Exact per-type entry — the mapping *is* the full Rust path.
1176    if let Some((_, rust)) = extern_paths.iter().find(|(proto, _)| proto == fqn) {
1177        return Some(rust.clone());
1178    }
1179
1180    // 2. Longest dotted-prefix entry (package or enclosing type).
1181    let mut best: Option<(&str, &str, usize)> = None;
1182    for (proto_prefix, rust_prefix) in extern_paths {
1183        let matches = proto_prefix == "."
1184            || fqn
1185                .strip_prefix(proto_prefix.as_str())
1186                .is_some_and(|rest| rest.starts_with('.'));
1187        // MSRV: `Option::is_none_or` requires 1.82.
1188        if matches && best.map_or(true, |(_, _, best_len)| proto_prefix.len() > best_len) {
1189            best = Some((proto_prefix, rust_prefix, proto_prefix.len()));
1190        }
1191    }
1192
1193    let (proto_prefix, rust_prefix, _) = best?;
1194    // The catch-all `.` leaves the whole FQN (minus its leading dot); any other
1195    // prefix leaves the `.`-separated remainder after the matched boundary.
1196    let rest = if proto_prefix == "." {
1197        fqn.strip_prefix('.').unwrap_or(fqn)
1198    } else {
1199        fqn.strip_prefix(proto_prefix)
1200            .and_then(|r| r.strip_prefix('.'))
1201            .unwrap_or("")
1202    };
1203    let mut segments = rest.split('.').collect::<Vec<_>>();
1204    // The final segment is the type name (kept verbatim); the rest are modules.
1205    let type_name = segments.pop()?;
1206    let mut path = rust_prefix.to_string();
1207    for module in segments {
1208        path.push_str("::");
1209        path.push_str(&to_snake_case(module));
1210    }
1211    path.push_str("::");
1212    path.push_str(type_name);
1213    Some(path)
1214}
1215
1216/// Join a Rust module path and a type name, handling the empty (no-package)
1217/// module by returning the bare type name.
1218fn join_mod(module: &str, name: &str) -> String {
1219    if module.is_empty() {
1220        name.to_string()
1221    } else {
1222        format!("{module}::{name}")
1223    }
1224}
1225
1226/// Resolve a registered type's Rust path, applying per-type `extern_path`
1227/// overrides (issue #111).
1228///
1229/// Priority, most specific first: an **exact** per-type FQN entry, then a
1230/// **file-level** mapping (the internal `descriptor.proto` → `buffa-descriptor`
1231/// split, which must outrank a package prefix), then the **longest
1232/// dotted-prefix** entry (package or enclosing type, via [`resolve_extern_type`]),
1233/// then the **local** package path.
1234///
1235/// Returns `(rust_path, is_extern)`; `is_extern` is `false` only for the local
1236/// fallback, telling the caller whether the type emits a local module (and thus
1237/// participates in sub-package deconfliction, issue #135).
1238fn resolve_type_path(
1239    fqn: &str,
1240    name: &str,
1241    file_root: Option<&str>,
1242    local_module: &str,
1243    extern_paths: &[(String, String)],
1244    type_name_prefix: &str,
1245) -> (String, bool) {
1246    // The exact check is done here (not left to `resolve_extern_type`) so an
1247    // exact per-type entry outranks the file-level mapping below; once it has
1248    // failed, `resolve_extern_type`'s own exact pass can only fall through to
1249    // its prefix logic.
1250    //
1251    // `type_name_prefix` applies only to the local fallback: extern-mapped
1252    // types are named by the external crate, and the file-level root is the
1253    // internal descriptor.proto → buffa-descriptor split (also external to
1254    // this codegen run).
1255    if let Some((_, rust)) = extern_paths.iter().find(|(proto, _)| proto == fqn) {
1256        (rust.clone(), true)
1257    } else if let Some(root) = file_root {
1258        (join_mod(root, name), true)
1259    } else if let Some(path) = resolve_extern_type(fqn, extern_paths) {
1260        (path, true)
1261    } else {
1262        (
1263            join_mod(local_module, &format!("{type_name_prefix}{name}")),
1264            false,
1265        )
1266    }
1267}
1268
1269/// Per-file invariants of [`register_nested_types`], bundled so the
1270/// recursion only threads the parent FQN / module that actually vary.
1271#[derive(Clone, Copy)]
1272struct NestedRegistrationCtx<'a> {
1273    package: &'a str,
1274    extern_paths: &'a [(String, String)],
1275    type_name_prefix: &'a str,
1276}
1277
1278/// Recursively register nested messages and enums with module-qualified paths.
1279///
1280/// Each nested message `Parent.Child` maps to `parent_mod::Child` in Rust,
1281/// where `parent_mod` is the snake_case module path of the enclosing message.
1282///
1283/// A per-type `extern_path` override (issue #111) on a nested type's own FQN
1284/// takes priority over the inherited `parent_mod` path; otherwise the nested
1285/// type simply lives in `parent_mod` — which already reflects any override of an
1286/// enclosing type, so a parent override cascades to its descendants.
1287fn register_nested_types(
1288    type_map: &mut HashMap<String, String>,
1289    package_of: &mut HashMap<String, String>,
1290    reg: NestedRegistrationCtx<'_>,
1291    parent_fqn: &str,
1292    parent_mod: &str,
1293    msg: &crate::generated::descriptor::DescriptorProto,
1294) {
1295    let NestedRegistrationCtx {
1296        package,
1297        extern_paths,
1298        type_name_prefix,
1299    } = reg;
1300    for nested in &msg.nested_type {
1301        if let Some(name) = &nested.name {
1302            let fqn = format!("{}.{}", parent_fqn, name);
1303            // An exact per-type override wins; the child module is then the
1304            // override's parent plus the plain snake_case name. Otherwise the
1305            // type lives in `parent_mod`, named with the configured prefix
1306            // (the module segment stays the proto-derived snake_case name —
1307            // modules never collide with type names).
1308            let (rust_path, child_mod) = match extern_paths.iter().find(|(proto, _)| proto == &fqn)
1309            {
1310                Some((_, rust)) => {
1311                    let child = match rust.rsplit_once("::") {
1312                        Some((parent, _)) => format!("{parent}::{}", to_snake_case(name)),
1313                        None => to_snake_case(name),
1314                    };
1315                    (rust.clone(), child)
1316                }
1317                None => (
1318                    format!("{parent_mod}::{type_name_prefix}{name}"),
1319                    format!("{parent_mod}::{}", to_snake_case(name)),
1320                ),
1321            };
1322            type_map.insert(fqn.clone(), rust_path);
1323            package_of.insert(fqn.clone(), package.to_string());
1324
1325            // Recurse: nested-of-nested goes in a deeper module.
1326            register_nested_types(type_map, package_of, reg, &fqn, &child_mod, nested);
1327        }
1328    }
1329
1330    for enum_type in &msg.enum_type {
1331        if let Some(name) = &enum_type.name {
1332            let fqn = format!("{}.{}", parent_fqn, name);
1333            let rust_path = extern_paths
1334                .iter()
1335                .find(|(proto, _)| proto == &fqn)
1336                .map(|(_, rust)| rust.clone())
1337                .unwrap_or_else(|| format!("{parent_mod}::{type_name_prefix}{name}"));
1338            type_map.insert(fqn.clone(), rust_path);
1339            package_of.insert(fqn, package.to_string());
1340        }
1341    }
1342}
1343
1344/// Resolve and record whether an enum is closed, given its parent's features.
1345fn register_enum_closedness(
1346    map: &mut HashMap<String, bool>,
1347    fqn: &str,
1348    parent_features: &ResolvedFeatures,
1349    enum_desc: &EnumDescriptorProto,
1350) {
1351    let resolved = features::resolve_child(parent_features, features::enum_features(enum_desc));
1352    let closed = resolved.enum_type == features::EnumType::Closed;
1353    map.insert(fqn.to_string(), closed);
1354}
1355
1356/// Walk nested messages and register all enum closedness, resolving features
1357/// through the message hierarchy (file → msg → nested_msg → enum).
1358fn register_nested_enum_closedness(
1359    map: &mut HashMap<String, bool>,
1360    parent_fqn: &str,
1361    parent_features: &ResolvedFeatures,
1362    msg: &DescriptorProto,
1363) {
1364    let msg_features = features::resolve_child(parent_features, features::message_features(msg));
1365    for enum_type in &msg.enum_type {
1366        if let Some(name) = &enum_type.name {
1367            let fqn = format!("{}.{}", parent_fqn, name);
1368            register_enum_closedness(map, &fqn, &msg_features, enum_type);
1369        }
1370    }
1371    for nested in &msg.nested_type {
1372        if let Some(name) = &nested.name {
1373            let fqn = format!("{}.{}", parent_fqn, name);
1374            register_nested_enum_closedness(map, &fqn, &msg_features, nested);
1375        }
1376    }
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::*;
1382    use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
1383
1384    fn children(segs: &[&str]) -> HashSet<String> {
1385        segs.iter().map(|s| s.to_string()).collect()
1386    }
1387
1388    fn names(ns: &[&str]) -> Vec<String> {
1389        ns.iter().map(|s| s.to_string()).collect()
1390    }
1391
1392    #[test]
1393    fn deconflict_no_collision_keeps_base() {
1394        // No sub-package shares a module name → names are unchanged.
1395        let out = deconflict_package_modules(&names(&["Oof", "Bar"]), &children(&["other"]));
1396        assert_eq!(out, vec!["oof".to_string(), "bar".to_string()]);
1397    }
1398
1399    #[test]
1400    fn deconflict_single_collision_appends_underscore() {
1401        let out = deconflict_package_modules(&names(&["Oof"]), &children(&["oof"]));
1402        assert_eq!(out, vec!["oof_".to_string()]);
1403    }
1404
1405    #[test]
1406    fn deconflict_repeated_append_when_underscore_slot_also_taken() {
1407        // Sub-packages `oof` AND `oof_` both exist → grow past both.
1408        let out = deconflict_package_modules(&names(&["Oof"]), &children(&["oof", "oof_"]));
1409        assert_eq!(out, vec!["oof__".to_string()]);
1410    }
1411
1412    #[test]
1413    fn deconflict_two_messages_racing_to_same_slot_stay_distinct() {
1414        // `Oof` (oof) and `Oof_` (oof_), sub-packages `oof` and `oof_`. Without
1415        // the shared `taken` set both would land on `oof__`.
1416        let out = deconflict_package_modules(&names(&["Oof", "Oof_"]), &children(&["oof", "oof_"]));
1417        assert_eq!(out, vec!["oof__".to_string(), "oof___".to_string()]);
1418        // All distinct and clear of the sub-package modules.
1419        let set: HashSet<&String> = out.iter().collect();
1420        assert_eq!(set.len(), out.len());
1421        assert!(!out.contains(&"oof".to_string()) && !out.contains(&"oof_".to_string()));
1422    }
1423
1424    #[test]
1425    fn deconflict_is_independent_of_declaration_order() {
1426        // Reordering the input must not change which message gets which name.
1427        let ch = children(&["oof", "oof_"]);
1428        let fwd = deconflict_package_modules(&names(&["Oof", "Oof_"]), &ch);
1429        let rev = deconflict_package_modules(&names(&["Oof_", "Oof"]), &ch);
1430        // fwd: [Oof, Oof_]; rev: [Oof_, Oof] — same per-name mapping either way.
1431        assert_eq!(fwd, vec!["oof__".to_string(), "oof___".to_string()]);
1432        assert_eq!(rev, vec!["oof___".to_string(), "oof__".to_string()]);
1433    }
1434
1435    #[test]
1436    fn deconflict_avoids_other_messages_raw_base() {
1437        // `Oof` collides with sub-package `oof`; its `oof_` candidate must also
1438        // avoid the raw module of a sibling message `Oof_`.
1439        let out = deconflict_package_modules(&names(&["Oof", "Oof_"]), &children(&["oof"]));
1440        // Oof -> oof_ is taken by Oof_'s raw base, so Oof -> oof__; Oof_ stays.
1441        assert_eq!(out, vec!["oof__".to_string(), "oof_".to_string()]);
1442    }
1443
1444    #[test]
1445    fn deconflict_never_yields_the_sentinel() {
1446        let out = deconflict_package_modules(&names(&["Buffa"]), &children(&["__buffa", "buffa"]));
1447        // base `buffa` collides; `buffa_` is free, so it is chosen (not __buffa).
1448        assert_eq!(out, vec!["buffa_".to_string()]);
1449        assert_ne!(out[0], SENTINEL_MOD);
1450    }
1451
1452    #[test]
1453    fn child_package_segments_extracts_immediate_segment() {
1454        let pkgs = children(&["foo", "foo.oof", "foo.bar.baz", "foobar"]);
1455        let mut got: Vec<String> = child_package_segments("foo", &pkgs).into_iter().collect();
1456        got.sort();
1457        // `foo.oof` -> oof, `foo.bar.baz` -> bar; `foobar` is not a sub-package.
1458        assert_eq!(got, vec!["bar".to_string(), "oof".to_string()]);
1459    }
1460
1461    fn make_file(
1462        name: &str,
1463        package: &str,
1464        messages: Vec<DescriptorProto>,
1465        enums: Vec<EnumDescriptorProto>,
1466    ) -> FileDescriptorProto {
1467        FileDescriptorProto {
1468            name: Some(name.to_string()),
1469            package: if package.is_empty() {
1470                None
1471            } else {
1472                Some(package.to_string())
1473            },
1474            message_type: messages,
1475            enum_type: enums,
1476            ..Default::default()
1477        }
1478    }
1479
1480    fn msg(name: &str) -> DescriptorProto {
1481        DescriptorProto {
1482            name: Some(name.to_string()),
1483            ..Default::default()
1484        }
1485    }
1486
1487    fn msg_with_nested(name: &str, nested: Vec<DescriptorProto>) -> DescriptorProto {
1488        DescriptorProto {
1489            name: Some(name.to_string()),
1490            nested_type: nested,
1491            ..Default::default()
1492        }
1493    }
1494
1495    fn msg_with_nested_and_enums(
1496        name: &str,
1497        nested: Vec<DescriptorProto>,
1498        enums: Vec<EnumDescriptorProto>,
1499    ) -> DescriptorProto {
1500        DescriptorProto {
1501            name: Some(name.to_string()),
1502            nested_type: nested,
1503            enum_type: enums,
1504            ..Default::default()
1505        }
1506    }
1507
1508    fn enum_desc(name: &str) -> EnumDescriptorProto {
1509        EnumDescriptorProto {
1510            name: Some(name.to_string()),
1511            ..Default::default()
1512        }
1513    }
1514
1515    fn enum_with_closed_feature(name: &str) -> EnumDescriptorProto {
1516        use crate::generated::descriptor::{feature_set, EnumOptions, FeatureSet};
1517        EnumDescriptorProto {
1518            name: Some(name.to_string()),
1519            options: buffa::MessageField::some(EnumOptions {
1520                features: buffa::MessageField::some(FeatureSet {
1521                    enum_type: Some(feature_set::EnumType::CLOSED),
1522                    ..Default::default()
1523                }),
1524                ..Default::default()
1525            }),
1526            ..Default::default()
1527        }
1528    }
1529
1530    fn editions_file(
1531        name: &str,
1532        package: &str,
1533        messages: Vec<DescriptorProto>,
1534        enums: Vec<EnumDescriptorProto>,
1535    ) -> FileDescriptorProto {
1536        use crate::generated::descriptor::Edition;
1537        FileDescriptorProto {
1538            name: Some(name.to_string()),
1539            package: Some(package.to_string()),
1540            syntax: Some("editions".to_string()),
1541            edition: Some(Edition::EDITION_2023),
1542            message_type: messages,
1543            enum_type: enums,
1544            ..Default::default()
1545        }
1546    }
1547
1548    // ── Type registration tests ──────────────────────────────────────────
1549
1550    #[test]
1551    fn test_message_with_package() {
1552        let files = [make_file(
1553            "test.proto",
1554            "my.package",
1555            vec![msg("Foo")],
1556            vec![],
1557        )];
1558        let config = CodeGenConfig::default();
1559        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1560        assert_eq!(ctx.rust_type(".my.package.Foo"), Some("my::package::Foo"));
1561    }
1562
1563    #[test]
1564    fn test_message_no_package() {
1565        let files = [make_file("test.proto", "", vec![msg("Bar")], vec![])];
1566        let config = CodeGenConfig::default();
1567        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1568        assert_eq!(ctx.rust_type(".Bar"), Some("Bar"));
1569    }
1570
1571    #[test]
1572    fn test_nested_message_uses_module_path() {
1573        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1574        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1575        let config = CodeGenConfig::default();
1576        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1577        assert_eq!(ctx.rust_type(".pkg.Outer"), Some("pkg::Outer"));
1578        // Nested types use module-qualified paths.
1579        assert_eq!(ctx.rust_type(".pkg.Outer.Inner"), Some("pkg::outer::Inner"));
1580    }
1581
1582    #[test]
1583    fn test_nested_message_no_package() {
1584        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1585        let files = [make_file("test.proto", "", vec![outer], vec![])];
1586        let config = CodeGenConfig::default();
1587        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1588        assert_eq!(ctx.rust_type(".Outer"), Some("Outer"));
1589        assert_eq!(ctx.rust_type(".Outer.Inner"), Some("outer::Inner"));
1590    }
1591
1592    #[test]
1593    fn test_deeply_nested_message() {
1594        let deep = msg_with_nested("A", vec![msg_with_nested("B", vec![msg("C")])]);
1595        let files = [make_file("test.proto", "pkg", vec![deep], vec![])];
1596        let config = CodeGenConfig::default();
1597        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1598        assert_eq!(ctx.rust_type(".pkg.A"), Some("pkg::A"));
1599        assert_eq!(ctx.rust_type(".pkg.A.B"), Some("pkg::a::B"));
1600        assert_eq!(ctx.rust_type(".pkg.A.B.C"), Some("pkg::a::b::C"));
1601    }
1602
1603    #[test]
1604    fn test_nested_enum_uses_module_path() {
1605        let outer = msg_with_nested_and_enums("Outer", vec![], vec![enum_desc("Status")]);
1606        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1607        let config = CodeGenConfig::default();
1608        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1609        assert_eq!(
1610            ctx.rust_type(".pkg.Outer.Status"),
1611            Some("pkg::outer::Status")
1612        );
1613    }
1614
1615    #[test]
1616    fn test_top_level_enum() {
1617        let files = [make_file(
1618            "test.proto",
1619            "pkg",
1620            vec![],
1621            vec![enum_desc("Status")],
1622        )];
1623        let config = CodeGenConfig::default();
1624        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1625        assert_eq!(ctx.rust_type(".pkg.Status"), Some("pkg::Status"));
1626    }
1627
1628    #[test]
1629    fn test_same_named_nested_types_in_different_parents_are_distinct() {
1630        let outer1 = msg_with_nested("Outer1", vec![msg("Inner")]);
1631        let outer2 = msg_with_nested("Outer2", vec![msg("Inner")]);
1632        let files = [make_file("a.proto", "pkg", vec![outer1, outer2], vec![])];
1633        let config = CodeGenConfig::default();
1634        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1635        // Different parent modules make them distinct.
1636        assert_eq!(
1637            ctx.rust_type(".pkg.Outer1.Inner"),
1638            Some("pkg::outer1::Inner")
1639        );
1640        assert_eq!(
1641            ctx.rust_type(".pkg.Outer2.Inner"),
1642            Some("pkg::outer2::Inner")
1643        );
1644        assert_ne!(
1645            ctx.rust_type(".pkg.Outer1.Inner"),
1646            ctx.rust_type(".pkg.Outer2.Inner")
1647        );
1648    }
1649
1650    #[test]
1651    fn test_multiple_files() {
1652        let files = [
1653            make_file("a.proto", "ns.a", vec![msg("MsgA")], vec![]),
1654            make_file("b.proto", "ns.b", vec![msg("MsgB")], vec![]),
1655        ];
1656        let config = CodeGenConfig::default();
1657        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1658        assert_eq!(ctx.rust_type(".ns.a.MsgA"), Some("ns::a::MsgA"));
1659        assert_eq!(ctx.rust_type(".ns.b.MsgB"), Some("ns::b::MsgB"));
1660    }
1661
1662    // ── Per-type FQN extern_path resolution (issue #111) ──────────────────
1663    //
1664    // `extern_path` historically matched only at the package-prefix level, so
1665    // a mapping naming a concrete type FQN (e.g.
1666    // `.google.protobuf.Timestamp=::pbjson_types::Timestamp`, a normal
1667    // prost/tonic idiom) was silently ignored. These tests pin the prost
1668    // `resolve_ident` behavior: an exact type FQN entry wins, otherwise the
1669    // longest dotted-prefix (package or enclosing type) applies.
1670
1671    #[test]
1672    fn test_extern_path_exact_per_type_match() {
1673        let files = [make_file(
1674            "test.proto",
1675            "test.pkg",
1676            vec![msg("Msg")],
1677            vec![],
1678        )];
1679        let config = CodeGenConfig {
1680            extern_paths: vec![(".test.pkg.Msg".into(), "::ext_crate::Msg".into())],
1681            ..Default::default()
1682        };
1683        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1684        // An exact per-type FQN mapping must be honored, not silently dropped.
1685        assert_eq!(ctx.rust_type(".test.pkg.Msg"), Some("::ext_crate::Msg"));
1686    }
1687
1688    #[test]
1689    fn test_extern_path_per_type_overrides_package_prefix() {
1690        // Package-level mapping covers the whole package; an exact per-type
1691        // entry overrides it for that one type (exact-match-first), while a
1692        // sibling still resolves via the package prefix.
1693        let files = [make_file(
1694            "test.proto",
1695            "test.pkg",
1696            vec![msg("Msg"), msg("Other")],
1697            vec![],
1698        )];
1699        let config = CodeGenConfig {
1700            extern_paths: vec![
1701                (".test.pkg".into(), "::pkg_crate".into()),
1702                (".test.pkg.Msg".into(), "::ext_crate::Msg".into()),
1703            ],
1704            ..Default::default()
1705        };
1706        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1707        assert_eq!(ctx.rust_type(".test.pkg.Msg"), Some("::ext_crate::Msg"));
1708        assert_eq!(ctx.rust_type(".test.pkg.Other"), Some("::pkg_crate::Other"));
1709    }
1710
1711    #[test]
1712    fn test_extern_path_nested_type_inherits_per_type_override() {
1713        // A per-type override of a parent message cascades to its nested
1714        // types via the snake_case module of the overridden path.
1715        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1716        let files = [make_file("test.proto", "test.pkg", vec![outer], vec![])];
1717        let config = CodeGenConfig {
1718            extern_paths: vec![(".test.pkg.Outer".into(), "::ext::Outer".into())],
1719            ..Default::default()
1720        };
1721        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1722        assert_eq!(ctx.rust_type(".test.pkg.Outer"), Some("::ext::Outer"));
1723        assert_eq!(
1724            ctx.rust_type(".test.pkg.Outer.Inner"),
1725            Some("::ext::outer::Inner")
1726        );
1727    }
1728
1729    #[test]
1730    fn test_extern_path_exact_per_type_enum() {
1731        let files = [make_file(
1732            "test.proto",
1733            "test.pkg",
1734            vec![],
1735            vec![enum_desc("Status")],
1736        )];
1737        let config = CodeGenConfig {
1738            extern_paths: vec![(".test.pkg.Status".into(), "::ext_crate::Status".into())],
1739            ..Default::default()
1740        };
1741        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1742        assert_eq!(
1743            ctx.rust_type(".test.pkg.Status"),
1744            Some("::ext_crate::Status")
1745        );
1746    }
1747
1748    #[test]
1749    fn test_extern_path_package_prefix_still_resolves() {
1750        // Guard: package-prefix mappings (the only historically-supported
1751        // form) must keep resolving exactly as before.
1752        let files = [make_file(
1753            "test.proto",
1754            "test.pkg",
1755            vec![msg("Msg")],
1756            vec![],
1757        )];
1758        let config = CodeGenConfig {
1759            extern_paths: vec![(".test.pkg".into(), "::pkg_crate".into())],
1760            ..Default::default()
1761        };
1762        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1763        assert_eq!(ctx.rust_type(".test.pkg.Msg"), Some("::pkg_crate::Msg"));
1764    }
1765
1766    #[test]
1767    fn test_extern_path_per_type_does_not_affect_unmapped_type() {
1768        // Guard: a per-type entry must not leak onto an unrelated type.
1769        let files = [make_file(
1770            "test.proto",
1771            "test.pkg",
1772            vec![msg("Msg"), msg("Other")],
1773            vec![],
1774        )];
1775        let config = CodeGenConfig {
1776            extern_paths: vec![(".test.pkg.Msg".into(), "::ext_crate::Msg".into())],
1777            ..Default::default()
1778        };
1779        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1780        assert_eq!(ctx.rust_type(".test.pkg.Msg"), Some("::ext_crate::Msg"));
1781        // `Other` has no entry — resolves locally.
1782        assert_eq!(ctx.rust_type(".test.pkg.Other"), Some("test::pkg::Other"));
1783    }
1784
1785    #[test]
1786    fn test_keyword_package_segment_in_type_map() {
1787        // Proto package `google.type` — the type map stores plain string paths.
1788        // Keyword escaping happens at the token level, not in the type map.
1789        let files = [make_file(
1790            "latlng.proto",
1791            "google.type",
1792            vec![msg("LatLng")],
1793            vec![],
1794        )];
1795        let config = CodeGenConfig::default();
1796        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1797        assert_eq!(
1798            ctx.rust_type(".google.type.LatLng"),
1799            Some("google::type::LatLng")
1800        );
1801    }
1802
1803    #[test]
1804    fn test_keyword_package_relative_same_package() {
1805        let files = [make_file(
1806            "latlng.proto",
1807            "google.type",
1808            vec![msg("LatLng"), msg("Expr")],
1809            vec![],
1810        )];
1811        let config = CodeGenConfig::default();
1812        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1813        // Same-package reference: just the type name (no module prefix).
1814        assert_eq!(
1815            ctx.rust_type_relative(".google.type.LatLng", "google.type", 0),
1816            Some("LatLng".into())
1817        );
1818    }
1819
1820    #[test]
1821    fn test_keyword_package_cross_package() {
1822        let files = [
1823            make_file("latlng.proto", "google.type", vec![msg("LatLng")], vec![]),
1824            make_file("svc.proto", "google.cloud", vec![msg("Service")], vec![]),
1825        ];
1826        let config = CodeGenConfig::default();
1827        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1828        // Cross-package: relative path via super:: (keyword escaping at token level).
1829        // From google.cloud, go up one (past "cloud"), then into "type".
1830        assert_eq!(
1831            ctx.rust_type_relative(".google.type.LatLng", "google.cloud", 0),
1832            Some("super::type::LatLng".into())
1833        );
1834    }
1835
1836    #[test]
1837    fn test_keyword_nested_message_module() {
1838        // Message named "Type" → module "type" in type map.
1839        let outer = msg_with_nested("Type", vec![msg("Inner")]);
1840        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1841        let config = CodeGenConfig::default();
1842        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1843        assert_eq!(ctx.rust_type(".pkg.Type"), Some("pkg::Type"));
1844        assert_eq!(ctx.rust_type(".pkg.Type.Inner"), Some("pkg::type::Inner"));
1845    }
1846
1847    #[test]
1848    fn test_unknown_type_returns_none() {
1849        let files = [make_file("test.proto", "pkg", vec![msg("Foo")], vec![])];
1850        let config = CodeGenConfig::default();
1851        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1852        assert_eq!(ctx.rust_type(".pkg.Unknown"), None);
1853    }
1854
1855    // ── Relative type resolution tests ───────────────────────────────────
1856
1857    #[test]
1858    fn test_relative_same_package_top_level() {
1859        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
1860        let config = CodeGenConfig::default();
1861        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1862        // From top-level in same package: just the type name.
1863        assert_eq!(
1864            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
1865            Some("Foo".into())
1866        );
1867    }
1868
1869    #[test]
1870    fn test_relative_cross_package() {
1871        let files = [
1872            make_file("a.proto", "pkg_a", vec![msg("Foo")], vec![]),
1873            make_file("b.proto", "pkg_b", vec![msg("Bar")], vec![]),
1874        ];
1875        let config = CodeGenConfig::default();
1876        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1877        // Cross-package: relative via super:: (up one from pkg_b, into pkg_a).
1878        assert_eq!(
1879            ctx.rust_type_relative(".pkg_a.Foo", "pkg_b", 0),
1880            Some("super::pkg_a::Foo".into())
1881        );
1882    }
1883
1884    #[test]
1885    fn test_relative_no_package() {
1886        let files = [make_file("a.proto", "", vec![msg("Foo")], vec![])];
1887        let config = CodeGenConfig::default();
1888        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1889        assert_eq!(ctx.rust_type_relative(".Foo", "", 0), Some("Foo".into()));
1890    }
1891
1892    #[test]
1893    fn test_relative_unknown_returns_none() {
1894        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
1895        let config = CodeGenConfig::default();
1896        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1897        assert_eq!(ctx.rust_type_relative(".pkg.Unknown", "pkg", 0), None);
1898    }
1899
1900    #[test]
1901    fn test_relative_dotted_package() {
1902        let files = [make_file("a.proto", "my.pkg", vec![msg("Foo")], vec![])];
1903        let config = CodeGenConfig::default();
1904        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1905        assert_eq!(
1906            ctx.rust_type_relative(".my.pkg.Foo", "my.pkg", 0),
1907            Some("Foo".into())
1908        );
1909    }
1910
1911    #[test]
1912    fn test_relative_cross_dotted_packages() {
1913        let files = [
1914            make_file(
1915                "timestamp.proto",
1916                "google.protobuf",
1917                vec![msg("Timestamp")],
1918                vec![],
1919            ),
1920            make_file(
1921                "test.proto",
1922                "protobuf_test_messages.proto3",
1923                vec![msg("TestAllTypesProto3")],
1924                vec![],
1925            ),
1926        ];
1927        let config = CodeGenConfig::default();
1928        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1929
1930        // Cross-package: relative via super:: (no common prefix, up 2 levels).
1931        assert_eq!(
1932            ctx.rust_type_relative(
1933                ".google.protobuf.Timestamp",
1934                "protobuf_test_messages.proto3",
1935                0,
1936            ),
1937            Some("super::super::google::protobuf::Timestamp".into())
1938        );
1939    }
1940
1941    #[test]
1942    fn test_relative_nested_type_from_same_package() {
1943        // Referencing Outer.Inner from the same package.
1944        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1945        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1946        let config = CodeGenConfig::default();
1947        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1948
1949        // Same package: strips the package prefix, keeps module path.
1950        assert_eq!(
1951            ctx.rust_type_relative(".pkg.Outer.Inner", "pkg", 0),
1952            Some("outer::Inner".into())
1953        );
1954    }
1955
1956    #[test]
1957    fn test_relative_shared_prefix_not_confused() {
1958        let files = [
1959            make_file("ab.proto", "a.b", vec![msg("Msg1")], vec![]),
1960            make_file("abc.proto", "a.bc", vec![msg("Msg2")], vec![]),
1961        ];
1962        let config = CodeGenConfig::default();
1963        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1964
1965        // `a.b.Msg1` from `a.bc` context: common prefix "a", up 1, into "b".
1966        assert_eq!(
1967            ctx.rust_type_relative(".a.b.Msg1", "a.bc", 0),
1968            Some("super::b::Msg1".into())
1969        );
1970        // `a.bc.Msg2` from `a.b` context: common prefix "a", up 1, into "bc".
1971        assert_eq!(
1972            ctx.rust_type_relative(".a.bc.Msg2", "a.b", 0),
1973            Some("super::bc::Msg2".into())
1974        );
1975    }
1976
1977    // ── Nesting depth tests ────────────────────────────────────────────
1978
1979    #[test]
1980    fn test_relative_cross_package_nesting_1() {
1981        // Simulates a nested message (inside a `pub mod`) referencing a type
1982        // from a sibling package. E.g., account.business.admin.v1 nested msg
1983        // referencing account.business.v1.Business.Status.
1984        let outer = msg_with_nested_and_enums("Business", vec![], vec![enum_desc("Status")]);
1985        let files = [
1986            make_file("admin.proto", "a.b.admin.v1", vec![msg("Svc")], vec![]),
1987            make_file("biz.proto", "a.b.v1", vec![outer], vec![]),
1988        ];
1989        let config = CodeGenConfig::default();
1990        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1991
1992        // nesting=0 (top-level struct in admin.v1): up 2 (v1→admin), into v1
1993        assert_eq!(
1994            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 0),
1995            Some("super::super::v1::business::Status".into())
1996        );
1997        // nesting=1 (inside a nested message module): one extra super::
1998        assert_eq!(
1999            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 1),
2000            Some("super::super::super::v1::business::Status".into())
2001        );
2002    }
2003
2004    #[test]
2005    fn test_relative_same_package_nesting_1() {
2006        // Nested message referencing a sibling type in the same package.
2007        let files = [make_file(
2008            "test.proto",
2009            "pkg",
2010            vec![msg("Foo"), msg("Bar")],
2011            vec![],
2012        )];
2013        let config = CodeGenConfig::default();
2014        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2015
2016        // nesting=0: same package, just the name
2017        assert_eq!(
2018            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
2019            Some("Foo".into())
2020        );
2021        // nesting=1: inside a message module, needs one super::
2022        assert_eq!(
2023            ctx.rust_type_relative(".pkg.Foo", "pkg", 1),
2024            Some("super::Foo".into())
2025        );
2026        // nesting=2: doubly nested
2027        assert_eq!(
2028            ctx.rust_type_relative(".pkg.Foo", "pkg", 2),
2029            Some("super::super::Foo".into())
2030        );
2031    }
2032
2033    // ── Extern path tests ─────────────────────────────────────────────
2034
2035    #[test]
2036    fn test_resolve_file_extern_exact_match_only() {
2037        let mappings = [(
2038            "google/protobuf/descriptor.proto".to_string(),
2039            "::buffa_descriptor::generated::descriptor".to_string(),
2040        )];
2041        // Exact file path matches.
2042        assert_eq!(
2043            resolve_file_extern("google/protobuf/descriptor.proto", &mappings),
2044            Some("::buffa_descriptor::generated::descriptor"),
2045        );
2046        // No prefix matching — a sibling file in the same directory does
2047        // not match (its types live in a different generated module).
2048        assert_eq!(
2049            resolve_file_extern("google/protobuf/timestamp.proto", &mappings),
2050            None,
2051        );
2052        // No suffix matching either.
2053        assert_eq!(
2054            resolve_file_extern("vendor/google/protobuf/descriptor.proto", &mappings),
2055            None,
2056        );
2057    }
2058
2059    #[test]
2060    fn test_resolve_extern_prefix_exact_match() {
2061        let result = resolve_extern_prefix(
2062            "my.common",
2063            &[(".my.common".into(), "::common_protos".into())],
2064        );
2065        assert_eq!(result, Some("::common_protos".into()));
2066    }
2067
2068    #[test]
2069    fn test_resolve_extern_prefix_sub_package() {
2070        let result = resolve_extern_prefix(
2071            "my.common.sub",
2072            &[(".my.common".into(), "::common_protos".into())],
2073        );
2074        assert_eq!(result, Some("::common_protos::sub".into()));
2075    }
2076
2077    #[test]
2078    fn test_resolve_extern_prefix_no_match() {
2079        let result = resolve_extern_prefix(
2080            "other.pkg",
2081            &[(".my.common".into(), "::common_protos".into())],
2082        );
2083        assert_eq!(result, None);
2084    }
2085
2086    #[test]
2087    fn test_resolve_extern_prefix_partial_name_no_match() {
2088        // ".my.common" should not match ".my.commonext"
2089        let result = resolve_extern_prefix(
2090            "my.commonext",
2091            &[(".my.common".into(), "::common_protos".into())],
2092        );
2093        assert_eq!(result, None);
2094    }
2095
2096    #[test]
2097    fn test_resolve_extern_prefix_longest_match_wins() {
2098        // When multiple prefixes match, the longest one should win.
2099        let result = resolve_extern_prefix(
2100            "my.common.sub",
2101            &[
2102                (".my".into(), "::crate_a".into()),
2103                (".my.common".into(), "::crate_b".into()),
2104            ],
2105        );
2106        assert_eq!(result, Some("::crate_b::sub".into()));
2107    }
2108
2109    #[test]
2110    fn test_resolve_extern_prefix_catchall() {
2111        let result = resolve_extern_prefix("greet.v1", &[(".".into(), "crate::proto".into())]);
2112        assert_eq!(result, Some("crate::proto::greet::v1".into()));
2113    }
2114
2115    #[test]
2116    fn test_resolve_extern_prefix_catchall_empty_pkg() {
2117        // Empty package with `.` catch-all hits the exact-match branch
2118        // (dotted == "." == proto_prefix) and returns the root as-is.
2119        let result = resolve_extern_prefix("", &[(".".into(), "crate::proto".into())]);
2120        assert_eq!(result, Some("crate::proto".into()));
2121    }
2122
2123    #[test]
2124    fn test_resolve_extern_prefix_catchall_longest_wins() {
2125        // `.` catch-all is the shortest possible prefix; any more-specific
2126        // mapping (including the auto-injected WKT mapping) takes priority.
2127        let result = resolve_extern_prefix(
2128            "google.protobuf",
2129            &[
2130                (".".into(), "crate::proto".into()),
2131                (
2132                    ".google.protobuf".into(),
2133                    "::buffa_types::google::protobuf".into(),
2134                ),
2135            ],
2136        );
2137        assert_eq!(result, Some("::buffa_types::google::protobuf".into()));
2138    }
2139
2140    #[test]
2141    fn test_resolve_extern_prefix_catchall_keyword_package() {
2142        // Keyword segments stay unescaped at the string level; escaping to
2143        // `r#type` happens later in `idents::rust_path_to_tokens`.
2144        let result = resolve_extern_prefix("google.type", &[(".".into(), "crate::proto".into())]);
2145        assert_eq!(result, Some("crate::proto::google::type".into()));
2146    }
2147
2148    // ── resolve_extern_type — per-type FQN resolution (issue #111) ─────────
2149
2150    #[test]
2151    fn test_resolve_extern_type_exact_match() {
2152        // An exact entry for the type FQN is the full Rust path verbatim.
2153        let result = resolve_extern_type(
2154            ".google.protobuf.Timestamp",
2155            &[(
2156                ".google.protobuf.Timestamp".into(),
2157                "::pbjson_types::Timestamp".into(),
2158            )],
2159        );
2160        assert_eq!(result, Some("::pbjson_types::Timestamp".into()));
2161    }
2162
2163    #[test]
2164    fn test_resolve_extern_type_exact_wins_over_prefix() {
2165        // Exact-match-first: the per-type entry beats a covering package entry.
2166        let result = resolve_extern_type(
2167            ".my.pkg.Msg",
2168            &[
2169                (".my.pkg".into(), "::pkg_crate".into()),
2170                (".my.pkg.Msg".into(), "::ext_crate::Msg".into()),
2171            ],
2172        );
2173        assert_eq!(result, Some("::ext_crate::Msg".into()));
2174    }
2175
2176    #[test]
2177    fn test_resolve_extern_type_package_prefix_appends_type() {
2178        // A package prefix yields `<rust_prefix>::<sub modules>::<TypeName>`,
2179        // matching what resolve_extern_prefix + the type name would build.
2180        let result = resolve_extern_type(
2181            ".my.common.sub.Msg",
2182            &[(".my.common".into(), "::common_protos".into())],
2183        );
2184        assert_eq!(result, Some("::common_protos::sub::Msg".into()));
2185    }
2186
2187    #[test]
2188    fn test_resolve_extern_type_catchall() {
2189        let result = resolve_extern_type(".greet.v1.Hello", &[(".".into(), "crate::proto".into())]);
2190        assert_eq!(result, Some("crate::proto::greet::v1::Hello".into()));
2191    }
2192
2193    #[test]
2194    fn test_resolve_extern_type_no_match() {
2195        let result = resolve_extern_type(
2196            ".other.pkg.Msg",
2197            &[(".my.common".into(), "::common_protos".into())],
2198        );
2199        assert_eq!(result, None);
2200    }
2201
2202    #[test]
2203    fn test_resolve_extern_type_partial_name_no_match() {
2204        // ".my.common" must not match ".my.commonext.Msg" (dot-boundary).
2205        let result = resolve_extern_type(
2206            ".my.commonext.Msg",
2207            &[(".my.common".into(), "::common_protos".into())],
2208        );
2209        assert_eq!(result, None);
2210    }
2211
2212    // ── rust_type_relative_split — extern branch ────────────────────────
2213
2214    #[test]
2215    fn test_split_extern_top_level() {
2216        let outer = msg_with_nested("Value", vec![msg("Inner")]);
2217        let files = [make_file(
2218            "struct.proto",
2219            "google.protobuf",
2220            vec![outer],
2221            vec![],
2222        )];
2223        let config = CodeGenConfig::default();
2224        let extern_paths = vec![(
2225            ".google.protobuf".into(),
2226            "::buffa_types::google::protobuf".into(),
2227        )];
2228        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
2229
2230        let split = ctx
2231            .rust_type_relative_split(".google.protobuf.Value", "my.pkg", 3)
2232            .expect("type resolves");
2233        assert!(split.is_extern);
2234        // Extern path is absolute → nesting irrelevant.
2235        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
2236        assert_eq!(split.within_package, "Value");
2237    }
2238
2239    #[test]
2240    fn test_split_extern_nested_type() {
2241        // Nested `.google.protobuf.Value.Inner` →
2242        // extern path `::buffa_types::google::protobuf::value::Inner`.
2243        // Segment-count slice: 2 within-package segments → cut after the
2244        // extern module prefix.
2245        let outer = msg_with_nested("Value", vec![msg("Inner")]);
2246        let files = [make_file(
2247            "struct.proto",
2248            "google.protobuf",
2249            vec![outer],
2250            vec![],
2251        )];
2252        let config = CodeGenConfig::default();
2253        let extern_paths = vec![(
2254            ".google.protobuf".into(),
2255            "::buffa_types::google::protobuf".into(),
2256        )];
2257        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
2258
2259        let split = ctx
2260            .rust_type_relative_split(".google.protobuf.Value.Inner", "my.pkg", 0)
2261            .expect("nested type resolves");
2262        assert!(split.is_extern);
2263        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
2264        assert_eq!(split.within_package, "value::Inner");
2265    }
2266
2267    #[test]
2268    fn test_split_per_type_extern_override() {
2269        // Per-type override (issue #111): the `__buffa::` boundary recovery must
2270        // still split the override path at the package/within-package seam, so
2271        // callers compose `<to_package>::__buffa::view::<within_package>`
2272        // correctly. Both the overridden type and its nested children are
2273        // exercised.
2274        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
2275        let files = [make_file("custom.proto", "my.pkg", vec![outer], vec![])];
2276        let config = CodeGenConfig {
2277            extern_paths: vec![(".my.pkg.Outer".into(), "::ext::custom::Outer".into())],
2278            ..Default::default()
2279        };
2280        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2281
2282        let split = ctx
2283            .rust_type_relative_split(".my.pkg.Outer", "other.pkg", 2)
2284            .expect("overridden type resolves");
2285        assert!(split.is_extern);
2286        assert_eq!(split.to_package, "::ext::custom");
2287        assert_eq!(split.within_package, "Outer");
2288
2289        // The nested type inherits the override's module, and the seam is
2290        // recovered the same way (2 within-package segments stripped).
2291        let nested = ctx
2292            .rust_type_relative_split(".my.pkg.Outer.Inner", "other.pkg", 0)
2293            .expect("nested type resolves");
2294        assert!(nested.is_extern);
2295        assert_eq!(nested.to_package, "::ext::custom");
2296        assert_eq!(nested.within_package, "outer::Inner");
2297    }
2298
2299    #[test]
2300    fn test_extern_path_top_level_message() {
2301        let files = [make_file(
2302            "common.proto",
2303            "my.common",
2304            vec![msg("SharedMsg")],
2305            vec![],
2306        )];
2307        let config = CodeGenConfig {
2308            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2309            ..Default::default()
2310        };
2311        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2312        assert_eq!(
2313            ctx.rust_type(".my.common.SharedMsg"),
2314            Some("::common_protos::SharedMsg")
2315        );
2316    }
2317
2318    #[test]
2319    fn test_extern_path_nested_message() {
2320        let files = [make_file(
2321            "common.proto",
2322            "my.common",
2323            vec![msg_with_nested("Outer", vec![msg("Inner")])],
2324            vec![],
2325        )];
2326        let config = CodeGenConfig {
2327            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2328            ..Default::default()
2329        };
2330        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2331        assert_eq!(
2332            ctx.rust_type(".my.common.Outer"),
2333            Some("::common_protos::Outer")
2334        );
2335        assert_eq!(
2336            ctx.rust_type(".my.common.Outer.Inner"),
2337            Some("::common_protos::outer::Inner")
2338        );
2339    }
2340
2341    #[test]
2342    fn test_extern_path_nested_types_use_deconflicted_module() {
2343        // Mirrors pb.lyft.Money.{Currency,Breakdown} vs sub-package pb.lyft.money.
2344        let money =
2345            msg_with_nested_and_enums("Money", vec![msg("Breakdown")], vec![enum_desc("Currency")]);
2346        let files = [
2347            make_file("lyft_money.proto", "pb.lyft", vec![money], vec![]),
2348            make_file("money.proto", "pb.lyft.money", vec![msg("Money")], vec![]),
2349            make_file(
2350                "users.proto",
2351                "pb.lyft.users",
2352                vec![msg("DailyTotalFares")],
2353                vec![],
2354            ),
2355        ];
2356        let config = CodeGenConfig {
2357            extern_paths: vec![(".pb.lyft".into(), "::idl_pb_lyft::pb::lyft".into())],
2358            ..Default::default()
2359        };
2360        let ctx = CodeGenContext::for_generate(&files, &["users.proto".to_string()], &config);
2361        assert_eq!(
2362            ctx.rust_type(".pb.lyft.Money.Currency"),
2363            Some("::idl_pb_lyft::pb::lyft::money_::Currency")
2364        );
2365        assert_eq!(
2366            ctx.rust_type(".pb.lyft.Money.Breakdown"),
2367            Some("::idl_pb_lyft::pb::lyft::money_::Breakdown")
2368        );
2369    }
2370
2371    #[test]
2372    fn test_extern_path_enum() {
2373        let files = [make_file(
2374            "common.proto",
2375            "my.common",
2376            vec![],
2377            vec![enum_desc("Status")],
2378        )];
2379        let config = CodeGenConfig {
2380            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2381            ..Default::default()
2382        };
2383        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2384        assert_eq!(
2385            ctx.rust_type(".my.common.Status"),
2386            Some("::common_protos::Status")
2387        );
2388    }
2389
2390    #[test]
2391    fn test_extern_path_does_not_affect_other_packages() {
2392        let files = [
2393            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
2394            make_file(
2395                "service.proto",
2396                "my.service",
2397                vec![msg("MyService")],
2398                vec![],
2399            ),
2400        ];
2401        let config = CodeGenConfig {
2402            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2403            ..Default::default()
2404        };
2405        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2406        // Extern type uses absolute path.
2407        assert_eq!(
2408            ctx.rust_type(".my.common.SharedMsg"),
2409            Some("::common_protos::SharedMsg")
2410        );
2411        // Non-extern type uses normal package-derived path.
2412        assert_eq!(
2413            ctx.rust_type(".my.service.MyService"),
2414            Some("my::service::MyService")
2415        );
2416    }
2417
2418    #[test]
2419    fn test_extern_path_relative_returns_absolute() {
2420        // When an extern type is referenced from another package,
2421        // rust_type_relative should return the full absolute path.
2422        let files = [
2423            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
2424            make_file(
2425                "service.proto",
2426                "my.service",
2427                vec![msg("MyService")],
2428                vec![],
2429            ),
2430        ];
2431        let config = CodeGenConfig {
2432            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2433            ..Default::default()
2434        };
2435        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2436        // Cross-package reference to extern type: absolute path.
2437        assert_eq!(
2438            ctx.rust_type_relative(".my.common.SharedMsg", "my.service", 0),
2439            Some("::common_protos::SharedMsg".into())
2440        );
2441    }
2442
2443    // ── is_enum_closed tests ──────────────────────────────────────────────
2444
2445    #[test]
2446    fn test_is_enum_closed_proto3_default_open() {
2447        let files = [make_file("a.proto", "p", vec![], vec![enum_desc("E")])];
2448        let config = CodeGenConfig::default();
2449        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2450        // proto3 default (make_file has no syntax = proto2/implicit)
2451        // actually make_file doesn't set syntax, so it's proto2 default...
2452        // proto2 default is CLOSED.
2453        assert_eq!(ctx.is_enum_closed(".p.E"), Some(true));
2454    }
2455
2456    #[test]
2457    fn test_is_enum_closed_editions_default_open() {
2458        let files = [editions_file("a.proto", "p", vec![], vec![enum_desc("E")])];
2459        let config = CodeGenConfig::default();
2460        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2461        // Edition 2023 default is OPEN.
2462        assert_eq!(ctx.is_enum_closed(".p.E"), Some(false));
2463    }
2464
2465    #[test]
2466    fn test_is_enum_closed_per_enum_override() {
2467        // This is THE bug: enum with `option features.enum_type = CLOSED`
2468        // in an otherwise-open editions file must be detected as closed.
2469        let files = [editions_file(
2470            "a.proto",
2471            "p",
2472            vec![],
2473            vec![enum_desc("Open"), enum_with_closed_feature("Closed")],
2474        )];
2475        let config = CodeGenConfig::default();
2476        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2477        assert_eq!(ctx.is_enum_closed(".p.Open"), Some(false));
2478        assert_eq!(ctx.is_enum_closed(".p.Closed"), Some(true));
2479    }
2480
2481    #[test]
2482    fn test_is_enum_closed_nested_per_enum_override() {
2483        // Feature resolution through file → message → enum.
2484        let files = [editions_file(
2485            "a.proto",
2486            "p",
2487            vec![msg_with_nested_and_enums(
2488                "M",
2489                vec![],
2490                vec![enum_with_closed_feature("Inner")],
2491            )],
2492            vec![],
2493        )];
2494        let config = CodeGenConfig::default();
2495        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2496        assert_eq!(ctx.is_enum_closed(".p.M.Inner"), Some(true));
2497    }
2498
2499    #[test]
2500    fn test_is_enum_closed_unknown_enum_returns_none() {
2501        let files = [editions_file("a.proto", "p", vec![], vec![])];
2502        let config = CodeGenConfig::default();
2503        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2504        // extern_path or missing enum → None (caller falls back).
2505        assert_eq!(ctx.is_enum_closed(".other.Unknown"), None);
2506    }
2507
2508    #[test]
2509    fn test_for_generate_auto_injects_wkt_mapping() {
2510        // for_generate() must produce the same type_map as generate() uses
2511        // internally — including the auto-injected WKT extern_path.
2512        let ts_msg = DescriptorProto {
2513            name: Some("Timestamp".into()),
2514            ..Default::default()
2515        };
2516        let files = [FileDescriptorProto {
2517            name: Some("google/protobuf/timestamp.proto".into()),
2518            package: Some("google.protobuf".into()),
2519            syntax: Some("proto3".into()),
2520            message_type: vec![ts_msg],
2521            ..Default::default()
2522        }];
2523        let config = CodeGenConfig::default();
2524        // Not generating the WKT file itself → auto-mapping should kick in.
2525        let ctx = CodeGenContext::for_generate(&files, &["other.proto".into()], &config);
2526        assert_eq!(
2527            ctx.rust_type(".google.protobuf.Timestamp"),
2528            Some("::buffa_types::google::protobuf::Timestamp"),
2529            "WKT auto-mapping must be applied via for_generate"
2530        );
2531    }
2532
2533    #[test]
2534    fn test_for_generate_suppresses_wkt_when_generating_wkt() {
2535        // When files_to_generate includes a google.protobuf file (building
2536        // buffa-types itself), the WKT auto-mapping must NOT be applied.
2537        let ts_msg = DescriptorProto {
2538            name: Some("Timestamp".into()),
2539            ..Default::default()
2540        };
2541        let files = [FileDescriptorProto {
2542            name: Some("google/protobuf/timestamp.proto".into()),
2543            package: Some("google.protobuf".into()),
2544            syntax: Some("proto3".into()),
2545            message_type: vec![ts_msg],
2546            ..Default::default()
2547        }];
2548        let config = CodeGenConfig::default();
2549        let ctx = CodeGenContext::for_generate(
2550            &files,
2551            &["google/protobuf/timestamp.proto".into()],
2552            &config,
2553        );
2554        // No extern mapping → local-package path.
2555        assert_eq!(
2556            ctx.rust_type(".google.protobuf.Timestamp"),
2557            Some("google::protobuf::Timestamp")
2558        );
2559    }
2560
2561    // ── matching_attributes tests ──────────────────────────────────────
2562
2563    #[test]
2564    fn test_matching_attributes_catchall() {
2565        // "." matches every type.
2566        let attrs = vec![(".".into(), "#[derive(Foo)]".into())];
2567        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2568        assert!(result.to_string().contains("derive"));
2569    }
2570
2571    #[test]
2572    fn test_matching_attributes_exact_match() {
2573        let attrs = vec![(".my.pkg.MyMessage".into(), "#[derive(Bar)]".into())];
2574        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2575        assert!(result.to_string().contains("derive"));
2576    }
2577
2578    #[test]
2579    fn test_matching_attributes_package_prefix() {
2580        let attrs = vec![(".my.pkg".into(), "#[derive(Baz)]".into())];
2581        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2582        assert!(result.to_string().contains("derive"));
2583    }
2584
2585    #[test]
2586    fn test_matching_attributes_no_partial_segment_match() {
2587        // ".my.pk" must not match ".my.pkg" (partial segment).
2588        let attrs = vec![(".my.pk".into(), "#[derive(Bad)]".into())];
2589        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2590        assert!(result.is_empty());
2591    }
2592
2593    #[test]
2594    fn test_matching_attributes_no_match() {
2595        let attrs = vec![(".other.pkg".into(), "#[derive(Nope)]".into())];
2596        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2597        assert!(result.is_empty());
2598    }
2599
2600    #[test]
2601    fn test_matching_attributes_multiple_accumulate() {
2602        // All matching entries are emitted, not just the first.
2603        let attrs = vec![
2604            (".".into(), "#[derive(A)]".into()),
2605            (".my.pkg".into(), "#[derive(B)]".into()),
2606        ];
2607        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2608        let s = result.to_string();
2609        assert!(s.contains("A") && s.contains("B"));
2610    }
2611
2612    #[test]
2613    fn test_matching_attributes_invalid_attr_errors() {
2614        // Unparseable attributes surface as a hard error so the user sees
2615        // the problem at build time rather than a silently-dropped attribute.
2616        let attrs = vec![(".".into(), "not valid {{{{".into())];
2617        let err = CodeGenContext::matching_attributes(&attrs, "my.pkg.Msg").unwrap_err();
2618        assert!(matches!(
2619            err,
2620            crate::CodeGenError::InvalidCustomAttribute { .. }
2621        ));
2622    }
2623
2624    #[test]
2625    fn test_matches_proto_prefix_catchall() {
2626        assert!(matches_proto_prefix(".", ".anything.here"));
2627        assert!(matches_proto_prefix(".", "."));
2628    }
2629
2630    #[test]
2631    fn test_matches_proto_prefix_segment_boundary() {
2632        // Segment-aware: ".my.pk" must not match ".my.pkg".
2633        assert!(!matches_proto_prefix(".my.pk", ".my.pkg.Msg"));
2634        // But full-segment prefix match does.
2635        assert!(matches_proto_prefix(".my.pkg", ".my.pkg.Msg"));
2636    }
2637}