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