Skip to main content

buffa_codegen/
context.rs

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