Skip to main content

buffa_codegen/
context.rs

1//! Code generation context and descriptor-to-Rust mapping state.
2
3use std::collections::HashMap;
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}
84
85impl<'a> CodeGenContext<'a> {
86    /// Build a context from file descriptors, populating the type map.
87    ///
88    /// `effective_extern_paths` includes both user-provided mappings and any
89    /// auto-injected defaults (e.g., the WKT mapping). These are computed by
90    /// `crate::effective_extern_paths` before calling this constructor.
91    pub fn new(
92        files: &'a [FileDescriptorProto],
93        config: &'a CodeGenConfig,
94        effective_extern_paths: &[(String, String)],
95    ) -> Self {
96        let mut type_map = HashMap::new();
97        let mut package_of = HashMap::new();
98        let mut enum_closedness = HashMap::new();
99        let mut comment_map = HashMap::new();
100
101        for file in files {
102            comment_map.extend(crate::comments::fqn_comments(file));
103            let package = file.package.as_deref().unwrap_or("");
104            let file_features = features::for_file(file);
105            let proto_prefix = if package.is_empty() {
106                String::from(".")
107            } else {
108                format!(".{}.", package)
109            };
110
111            // Check if this file's package matches an extern_path.
112            // If so, types are registered with the extern Rust path prefix.
113            let rust_module =
114                if let Some(rust_root) = resolve_extern_prefix(package, effective_extern_paths) {
115                    rust_root
116                } else {
117                    package.replace('.', "::")
118                };
119
120            // Register top-level messages
121            for msg in &file.message_type {
122                if let Some(name) = &msg.name {
123                    let fqn = format!("{}{}", proto_prefix, name);
124                    let rust_path = if rust_module.is_empty() {
125                        name.clone()
126                    } else {
127                        format!("{}::{}", rust_module, name)
128                    };
129                    type_map.insert(fqn.clone(), rust_path);
130                    package_of.insert(fqn.clone(), package.to_string());
131
132                    // Register nested messages using module-qualified paths.
133                    let snake = to_snake_case(name);
134                    let parent_mod = if rust_module.is_empty() {
135                        snake
136                    } else {
137                        format!("{}::{}", rust_module, snake)
138                    };
139                    register_nested_types(
140                        &mut type_map,
141                        &mut package_of,
142                        package,
143                        &fqn,
144                        &parent_mod,
145                        msg,
146                    );
147                    register_nested_enum_closedness(
148                        &mut enum_closedness,
149                        &fqn,
150                        &file_features,
151                        msg,
152                    );
153                }
154            }
155
156            // Register top-level enums
157            for enum_type in &file.enum_type {
158                if let Some(name) = &enum_type.name {
159                    let fqn = format!("{}{}", proto_prefix, name);
160                    let rust_path = if rust_module.is_empty() {
161                        name.clone()
162                    } else {
163                        format!("{}::{}", rust_module, name)
164                    };
165                    type_map.insert(fqn.clone(), rust_path);
166                    package_of.insert(fqn.clone(), package.to_string());
167                    register_enum_closedness(&mut enum_closedness, &fqn, &file_features, enum_type);
168                }
169            }
170        }
171
172        Self {
173            files,
174            config,
175            type_map,
176            package_of,
177            enum_closedness,
178            comment_map,
179        }
180    }
181
182    /// Build a context matching what [`generate()`](crate::generate) uses
183    /// internally.
184    ///
185    /// Computes effective extern paths (user-provided + auto-injected WKT
186    /// mapping to `buffa-types`) and builds the type map from them.
187    ///
188    /// Convenience for downstream generators (e.g. `connectrpc-codegen`)
189    /// that emit code alongside buffa's message types and need identical
190    /// type-path resolution. Using this instead of [`new()`](Self::new) +
191    /// manual extern-path computation ensures zero drift with buffa's own
192    /// generation.
193    pub fn for_generate(
194        files: &'a [FileDescriptorProto],
195        files_to_generate: &[String],
196        config: &'a CodeGenConfig,
197    ) -> Self {
198        let paths = crate::effective_extern_paths(files, files_to_generate, config);
199        Self::new(files, config, &paths)
200    }
201
202    /// Look up the Rust type path for a fully-qualified protobuf type name.
203    pub fn rust_type(&self, proto_fqn: &str) -> Option<&str> {
204        self.type_map.get(proto_fqn).map(|s| s.as_str())
205    }
206
207    /// Look up the source comment for a protobuf element by FQN.
208    ///
209    /// `fqn` uses the same dotted form as `proto_fqn` throughout codegen
210    /// (no leading dot). For sub-elements, append the element name:
211    /// - Message: `"pkg.Message"`
212    /// - Field: `"pkg.Message.field_name"`
213    /// - Enum value: `"pkg.Enum.VALUE_NAME"`
214    /// - Oneof: `"pkg.Message.oneof_name"`
215    pub fn comment(&self, fqn: &str) -> Option<&str> {
216        self.comment_map.get(fqn).map(|s| s.as_str())
217    }
218
219    /// Look up whether an enum (by fully-qualified proto name) is closed.
220    ///
221    /// Returns `None` if the enum is not in this compilation set (e.g., an
222    /// extern_path type), in which case callers should fall back to the
223    /// referencing field's feature chain (correct for proto2/proto3 where
224    /// `enum_type` is file-level anyway).
225    pub fn is_enum_closed(&self, proto_fqn: &str) -> Option<bool> {
226        self.enum_closedness.get(proto_fqn).copied()
227    }
228
229    /// Look up the Rust type path relative to the current code generation
230    /// scope.
231    ///
232    /// `current_package` is the proto package (e.g., `"google.protobuf"`).
233    /// `nesting` is the number of message module levels the generated code
234    /// sits inside (0 for struct fields and impls at the package level,
235    /// 1 for oneof enums inside a message module, etc.).
236    ///
237    /// - **Same package**: strips the package prefix and prepends `super::`
238    ///   for each nesting level.
239    /// - **Cross package (local)**: navigates via `super::` to the common
240    ///   ancestor, then descends into the target package. This works
241    ///   regardless of where the module tree is placed in the user's crate.
242    /// - **Cross package (extern)**: returns the absolute extern path as-is.
243    pub fn rust_type_relative(
244        &self,
245        proto_fqn: &str,
246        current_package: &str,
247        nesting: usize,
248    ) -> Option<String> {
249        let full_path = self.type_map.get(proto_fqn)?;
250
251        // Extern types use absolute paths (starting with `::` or `crate::`)
252        // and need no relative resolution — they work from any module position.
253        if full_path.starts_with("::") || full_path.starts_with("crate::") {
254            return Some(full_path.clone());
255        }
256
257        let target_package = self
258            .package_of
259            .get(proto_fqn)
260            .map(|s| s.as_str())
261            .unwrap_or("");
262
263        // Extract the type's path within its package (everything after the
264        // package module prefix).
265        let target_rust_module = target_package.replace('.', "::");
266        let type_suffix = if target_rust_module.is_empty() {
267            full_path.as_str()
268        } else {
269            full_path
270                .strip_prefix(&format!("{}::", target_rust_module))
271                .unwrap_or(full_path)
272        };
273
274        if current_package == target_package {
275            // Same package — just the type suffix, with super:: for nesting.
276            if nesting == 0 {
277                return Some(type_suffix.to_string());
278            }
279            let supers = (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::");
280            return Some(format!("{}::{}", supers, type_suffix));
281        }
282
283        // Cross-package local type: compute a super::-based relative path.
284        let current_parts: Vec<&str> = if current_package.is_empty() {
285            vec![]
286        } else {
287            current_package.split('.').collect()
288        };
289        let target_parts: Vec<&str> = if target_package.is_empty() {
290            vec![]
291        } else {
292            target_package.split('.').collect()
293        };
294
295        // Find the length of the common package prefix.
296        let common_len = current_parts
297            .iter()
298            .zip(&target_parts)
299            .take_while(|(a, b)| a == b)
300            .count();
301
302        // Navigate up: one super:: per remaining current package segment,
303        // plus one per nesting level (message module depth).
304        let up_count = (current_parts.len() - common_len) + nesting;
305
306        // Navigate down: target package segments beyond the common prefix.
307        let down_parts = &target_parts[common_len..];
308
309        let mut segments: Vec<&str> = vec!["super"; up_count];
310        segments.extend_from_slice(down_parts);
311
312        // Append the type's within-package path.
313        let mut result = segments.join("::");
314        if !result.is_empty() {
315            result.push_str("::");
316        }
317        result.push_str(type_suffix);
318
319        Some(result)
320    }
321
322    /// Like [`rust_type_relative`](Self::rust_type_relative) but returns the
323    /// path split at the target-package boundary.
324    ///
325    /// Ancillary kinds (views, oneof enums) live in the `__buffa::<kind>::`
326    /// sub-tree of each package; callers compose the final path as
327    /// `to_package + "::__buffa::" + <kind> + "::" + within_package`.
328    ///
329    /// `nesting` is the **total** module depth of the caller's emission
330    /// scope below the current package root — i.e. message-nesting plus any
331    /// `__buffa::<kind>::` levels the caller is already inside (0 for owned
332    /// types, +2 for `__buffa::view::`, +3 for `__buffa::view::oneof::`).
333    pub fn rust_type_relative_split(
334        &self,
335        proto_fqn: &str,
336        current_package: &str,
337        nesting: usize,
338    ) -> Option<SplitPath> {
339        let full_path = self.type_map.get(proto_fqn)?;
340
341        let target_package = self
342            .package_of
343            .get(proto_fqn)
344            .map(|s| s.as_str())
345            .unwrap_or("");
346
347        // Compute the type's path within its package (everything after the
348        // package module prefix). For extern types the prefix is the
349        // configured rust_module (e.g. `::buffa_types::google::protobuf`),
350        // not the bare dotted package, so derive it the same way `new()`
351        // populated the map.
352        let target_rust_module = if full_path.starts_with("::") || full_path.starts_with("crate::")
353        {
354            // Reconstruct the extern module prefix by stripping the
355            // within-package suffix length. We know the proto FQN's
356            // within-package portion (FQN minus package), so the full_path's
357            // last N segments correspond to it.
358            //
359            // Simpler: re-derive via `resolve_extern_prefix` would need the
360            // original extern_paths list. Instead, compute within-package
361            // from the proto FQN (which we know) and slice full_path.
362            let fqn_no_dot = proto_fqn.strip_prefix('.').unwrap_or(proto_fqn);
363            let within_proto = if target_package.is_empty() {
364                fqn_no_dot
365            } else {
366                fqn_no_dot
367                    .strip_prefix(target_package)
368                    .and_then(|s| s.strip_prefix('.'))
369                    .unwrap_or(fqn_no_dot)
370            };
371            // within_proto is dotted (e.g. "Outer.Inner"); within full_path
372            // it's `outer::Inner` (snake_case modules + final PascalCase).
373            // Count the segments and strip that many from full_path.
374            let within_segs = within_proto.split('.').count();
375            let full_segs: Vec<&str> = full_path.split("::").collect();
376            // Invariant: `full_path` was built by `CodeGenContext::new` as
377            // `<rust_module>::<within>`, so it always has at least
378            // `within_segs` trailing segments. If this fires the type_map
379            // and package_of maps are out of sync.
380            debug_assert!(
381                full_segs.len() >= within_segs,
382                "extern path '{full_path}' has fewer segments than \
383                 within-package proto path '{within_proto}'"
384            );
385            let cut = full_segs.len().saturating_sub(within_segs);
386            full_segs[..cut].join("::")
387        } else {
388            target_package.replace('.', "::")
389        };
390
391        let type_suffix = if target_rust_module.is_empty() {
392            full_path.as_str()
393        } else {
394            full_path
395                .strip_prefix(&format!("{}::", target_rust_module))
396                .unwrap_or(full_path)
397        };
398
399        // Extern: absolute path; nesting irrelevant.
400        if full_path.starts_with("::") || full_path.starts_with("crate::") {
401            return Some(SplitPath {
402                to_package: target_rust_module,
403                within_package: type_suffix.to_string(),
404                is_extern: true,
405            });
406        }
407
408        if current_package == target_package {
409            let to_package = if nesting == 0 {
410                String::new()
411            } else {
412                (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::")
413            };
414            return Some(SplitPath {
415                to_package,
416                within_package: type_suffix.to_string(),
417                is_extern: false,
418            });
419        }
420
421        // Cross-package local.
422        let current_parts: Vec<&str> = if current_package.is_empty() {
423            vec![]
424        } else {
425            current_package.split('.').collect()
426        };
427        let target_parts: Vec<&str> = if target_package.is_empty() {
428            vec![]
429        } else {
430            target_package.split('.').collect()
431        };
432        let common_len = current_parts
433            .iter()
434            .zip(&target_parts)
435            .take_while(|(a, b)| a == b)
436            .count();
437        let up_count = (current_parts.len() - common_len) + nesting;
438        let down_parts = &target_parts[common_len..];
439
440        let mut segments: Vec<&str> = vec!["super"; up_count];
441        segments.extend_from_slice(down_parts);
442
443        Some(SplitPath {
444            to_package: segments.join("::"),
445            within_package: type_suffix.to_string(),
446            is_extern: false,
447        })
448    }
449
450    /// Collect custom attributes matching a fully-qualified proto path.
451    ///
452    /// Returns a `TokenStream` of all `#[...]` attributes whose path prefix
453    /// matches `fqn`. Each attribute string is parsed via `syn::parse_str`
454    /// so the caller can interpolate directly into `quote!`.
455    ///
456    /// `fqn` uses dotted form without a leading dot (e.g., `"my.pkg.MyMessage"`).
457    ///
458    /// # Errors
459    ///
460    /// Returns `CodeGenError::InvalidCustomAttribute` if any matching attribute
461    /// string fails to parse as a valid Rust attribute.
462    pub(crate) fn matching_attributes(
463        attrs: &[(String, String)],
464        fqn: &str,
465    ) -> Result<proc_macro2::TokenStream, crate::CodeGenError> {
466        if attrs.is_empty() {
467            return Ok(proc_macro2::TokenStream::new());
468        }
469        let fqn_dotted = format!(".{fqn}");
470        let mut tokens = proc_macro2::TokenStream::new();
471        for (prefix, attr_str) in attrs {
472            if matches_proto_prefix(prefix, &fqn_dotted) {
473                let parsed =
474                    syn::parse_str::<proc_macro2::TokenStream>(attr_str).map_err(|err| {
475                        crate::CodeGenError::InvalidCustomAttribute {
476                            path: prefix.clone(),
477                            attribute: attr_str.clone(),
478                            detail: err.to_string(),
479                        }
480                    })?;
481                tokens.extend(parsed);
482            }
483        }
484        Ok(tokens)
485    }
486
487    /// Check whether a bytes field at the given proto path should use
488    /// `bytes::Bytes` instead of `Vec<u8>`.
489    ///
490    /// `field_fqn` is the fully-qualified proto field path, e.g.,
491    /// `".my.pkg.MyMessage.data"`. Matches against `config.bytes_fields`
492    /// entries using proto-segment-aware prefix matching: `"."` matches all,
493    /// `".my.pkg"` matches `".my.pkg.Msg.data"` but not `".my.pkgs.X.data"`.
494    pub fn use_bytes_type(&self, field_fqn: &str) -> bool {
495        self.config
496            .bytes_fields
497            .iter()
498            .any(|prefix| matches_proto_prefix(prefix, field_fqn))
499    }
500}
501
502/// Scope-local context for code generation within a message.
503///
504/// Bundles the parameters that are constant within a single message's code
505/// generation scope and change only when recursing into nested messages.
506/// Threading this struct instead of five individual parameters keeps function
507/// signatures short and makes adding new scope-level state a one-field change.
508#[derive(Clone, Copy)]
509pub(crate) struct MessageScope<'a> {
510    /// Global codegen context (descriptors, type map, config).
511    pub ctx: &'a CodeGenContext<'a>,
512    /// Proto package of the file being generated (e.g. `"google.protobuf"`).
513    pub current_package: &'a str,
514    /// Fully-qualified proto name of the current message
515    /// (e.g. `"google.protobuf.Timestamp"`, `"pkg.Outer.Inner"`).
516    pub proto_fqn: &'a str,
517    /// Resolved edition features for this message scope.
518    pub features: &'a ResolvedFeatures,
519    /// Module nesting depth — number of `pub mod` levels the generated code
520    /// sits inside.  Controls the count of `super::` prefixes in type
521    /// references via [`CodeGenContext::rust_type_relative`].
522    pub nesting: usize,
523}
524
525impl<'a> MessageScope<'a> {
526    /// Create a child scope for a nested message (increments nesting by 1).
527    pub fn nested(&self, proto_fqn: &'a str, features: &'a ResolvedFeatures) -> MessageScope<'a> {
528        MessageScope {
529            ctx: self.ctx,
530            current_package: self.current_package,
531            proto_fqn,
532            features,
533            nesting: self.nesting + 1,
534        }
535    }
536}
537
538/// Kind of ancillary tree under the [`SENTINEL_MOD`] module.
539///
540/// `path_segments()` returns the module path *inside* `__buffa::` (not
541/// including the sentinel itself).
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543pub(crate) enum AncillaryKind {
544    /// `__buffa::oneof::<msg_path>::` — owned oneof enums.
545    Oneof,
546    /// `__buffa::view::<msg_path>::` — message view structs.
547    View,
548    /// `__buffa::view::oneof::<msg_path>::` — view oneof enums.
549    ViewOneof,
550}
551
552impl AncillaryKind {
553    fn path_segments(self) -> &'static [&'static str] {
554        match self {
555            Self::Oneof => &["oneof"],
556            Self::View => &["view"],
557            Self::ViewOneof => &["view", "oneof"],
558        }
559    }
560}
561
562/// Build a token-stream path prefix from an emission scope to an ancillary
563/// kind's location for the **current** message (`proto_fqn`).
564///
565/// Always climbs to the package root via `super::` and re-descends through
566/// `__buffa::<kind>::<msg_path>::` — uniform regardless of where the caller
567/// sits. `from_nesting` is the caller's total module depth below the
568/// package root (message-nesting plus any `__buffa::<kind>::` levels the
569/// caller is already inside).
570///
571/// `proto_fqn` follows the dotless convention used throughout codegen
572/// (e.g. `"google.protobuf.Value"`, not `".google.protobuf.Value"`).
573///
574/// Returned tokens always end with `::` so callers append the type
575/// identifier directly: `quote! { #prefix #ident }`.
576pub(crate) fn ancillary_prefix(
577    kind: AncillaryKind,
578    current_package: &str,
579    proto_fqn: &str,
580    from_nesting: usize,
581) -> proc_macro2::TokenStream {
582    use crate::idents::make_field_ident;
583    use quote::quote;
584
585    debug_assert!(
586        !proto_fqn.starts_with('.'),
587        "ancillary_prefix expects dotless FQN, got {proto_fqn:?}"
588    );
589
590    let mut supers_tokens = proc_macro2::TokenStream::new();
591    for _ in 0..from_nesting {
592        supers_tokens.extend(quote! { super:: });
593    }
594
595    let sentinel = make_field_ident(SENTINEL_MOD);
596    let kind_segs: Vec<_> = kind
597        .path_segments()
598        .iter()
599        .map(|s| make_field_ident(s))
600        .collect();
601
602    // Snake-cased message path within the package (e.g. "outer::inner::").
603    let within_pkg = if current_package.is_empty() {
604        proto_fqn
605    } else {
606        proto_fqn
607            .strip_prefix(current_package)
608            .and_then(|s| s.strip_prefix('.'))
609            .unwrap_or(proto_fqn)
610    };
611    let msg_segs: Vec<_> = within_pkg
612        .split('.')
613        .filter(|s| !s.is_empty())
614        .map(|name| make_field_ident(&to_snake_case(name)))
615        .collect();
616
617    quote! { #supers_tokens #sentinel :: #(#kind_segs ::)* #(#msg_segs ::)* }
618}
619
620/// Proto-segment-aware prefix match: `prefix` matches `fqn_dotted` if
621/// `prefix == "."`, the two are equal, or `fqn_dotted` starts with `prefix`
622/// followed by a `.` boundary. Proto identifiers are ASCII, and `.` is ASCII,
623/// so byte indexing is safe.
624pub(crate) fn matches_proto_prefix(prefix: &str, fqn_dotted: &str) -> bool {
625    prefix == "."
626        || prefix == fqn_dotted
627        || (fqn_dotted.starts_with(prefix)
628            && fqn_dotted.as_bytes().get(prefix.len()) == Some(&b'.'))
629}
630
631/// Check if a proto package matches any extern_path prefix.
632///
633/// Returns the Rust module path root if matched, including any remaining
634/// package segments converted to `snake_case` modules. For example,
635/// extern_path `(".my", "::my_crate")` with package `"my.sub.pkg"` returns
636/// `"::my_crate::sub::pkg"`.
637fn resolve_extern_prefix(package: &str, extern_paths: &[(String, String)]) -> Option<String> {
638    let dotted = format!(".{}", package);
639
640    // Try longest prefix first so that more specific mappings take priority
641    // over broader ones (e.g., ".my.common" before ".my").
642    let mut best: Option<(&str, &str, usize)> = None;
643
644    for (proto_prefix, rust_prefix) in extern_paths {
645        if dotted == *proto_prefix {
646            // Exact match is always the best.
647            return Some(rust_prefix.clone());
648        }
649        if let Some(rest) = dotted.strip_prefix(proto_prefix.as_str()) {
650            // `"."` is the catch-all root; stripping it leaves no leading dot.
651            if proto_prefix == "." || rest.starts_with('.') {
652                let prefix_len = proto_prefix.len();
653                if best.is_none_or(|(_, _, best_len)| prefix_len > best_len) {
654                    best = Some((proto_prefix, rust_prefix, prefix_len));
655                }
656            }
657        }
658    }
659
660    let (proto_prefix, rust_prefix, _) = best?;
661    let rest = dotted.strip_prefix(proto_prefix)?;
662    let rest = rest.strip_prefix('.').unwrap_or(rest);
663    let suffix = rest
664        .split('.')
665        .map(to_snake_case)
666        .collect::<Vec<_>>()
667        .join("::");
668    Some(format!("{}::{}", rust_prefix, suffix))
669}
670
671/// Recursively register nested messages and enums with module-qualified paths.
672///
673/// Each nested message `Parent.Child` maps to `parent_mod::Child` in Rust,
674/// where `parent_mod` is the snake_case module path of the enclosing message.
675fn register_nested_types(
676    type_map: &mut HashMap<String, String>,
677    package_of: &mut HashMap<String, String>,
678    package: &str,
679    parent_fqn: &str,
680    parent_mod: &str,
681    msg: &crate::generated::descriptor::DescriptorProto,
682) {
683    for nested in &msg.nested_type {
684        if let Some(name) = &nested.name {
685            let fqn = format!("{}.{}", parent_fqn, name);
686            let rust_path = format!("{}::{}", parent_mod, name);
687            type_map.insert(fqn.clone(), rust_path);
688            package_of.insert(fqn.clone(), package.to_string());
689
690            // Recurse: nested-of-nested goes in a deeper module.
691            let child_mod = format!("{}::{}", parent_mod, to_snake_case(name));
692            register_nested_types(type_map, package_of, package, &fqn, &child_mod, nested);
693        }
694    }
695
696    for enum_type in &msg.enum_type {
697        if let Some(name) = &enum_type.name {
698            let fqn = format!("{}.{}", parent_fqn, name);
699            let rust_path = format!("{}::{}", parent_mod, name);
700            type_map.insert(fqn.clone(), rust_path);
701            package_of.insert(fqn, package.to_string());
702        }
703    }
704}
705
706/// Resolve and record whether an enum is closed, given its parent's features.
707fn register_enum_closedness(
708    map: &mut HashMap<String, bool>,
709    fqn: &str,
710    parent_features: &ResolvedFeatures,
711    enum_desc: &EnumDescriptorProto,
712) {
713    let resolved = features::resolve_child(parent_features, features::enum_features(enum_desc));
714    let closed = resolved.enum_type == features::EnumType::Closed;
715    map.insert(fqn.to_string(), closed);
716}
717
718/// Walk nested messages and register all enum closedness, resolving features
719/// through the message hierarchy (file → msg → nested_msg → enum).
720fn register_nested_enum_closedness(
721    map: &mut HashMap<String, bool>,
722    parent_fqn: &str,
723    parent_features: &ResolvedFeatures,
724    msg: &DescriptorProto,
725) {
726    let msg_features = features::resolve_child(parent_features, features::message_features(msg));
727    for enum_type in &msg.enum_type {
728        if let Some(name) = &enum_type.name {
729            let fqn = format!("{}.{}", parent_fqn, name);
730            register_enum_closedness(map, &fqn, &msg_features, enum_type);
731        }
732    }
733    for nested in &msg.nested_type {
734        if let Some(name) = &nested.name {
735            let fqn = format!("{}.{}", parent_fqn, name);
736            register_nested_enum_closedness(map, &fqn, &msg_features, nested);
737        }
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
745
746    fn make_file(
747        name: &str,
748        package: &str,
749        messages: Vec<DescriptorProto>,
750        enums: Vec<EnumDescriptorProto>,
751    ) -> FileDescriptorProto {
752        FileDescriptorProto {
753            name: Some(name.to_string()),
754            package: if package.is_empty() {
755                None
756            } else {
757                Some(package.to_string())
758            },
759            message_type: messages,
760            enum_type: enums,
761            ..Default::default()
762        }
763    }
764
765    fn msg(name: &str) -> DescriptorProto {
766        DescriptorProto {
767            name: Some(name.to_string()),
768            ..Default::default()
769        }
770    }
771
772    fn msg_with_nested(name: &str, nested: Vec<DescriptorProto>) -> DescriptorProto {
773        DescriptorProto {
774            name: Some(name.to_string()),
775            nested_type: nested,
776            ..Default::default()
777        }
778    }
779
780    fn msg_with_nested_and_enums(
781        name: &str,
782        nested: Vec<DescriptorProto>,
783        enums: Vec<EnumDescriptorProto>,
784    ) -> DescriptorProto {
785        DescriptorProto {
786            name: Some(name.to_string()),
787            nested_type: nested,
788            enum_type: enums,
789            ..Default::default()
790        }
791    }
792
793    fn enum_desc(name: &str) -> EnumDescriptorProto {
794        EnumDescriptorProto {
795            name: Some(name.to_string()),
796            ..Default::default()
797        }
798    }
799
800    fn enum_with_closed_feature(name: &str) -> EnumDescriptorProto {
801        use crate::generated::descriptor::{feature_set, EnumOptions, FeatureSet};
802        EnumDescriptorProto {
803            name: Some(name.to_string()),
804            options: buffa::MessageField::some(EnumOptions {
805                features: buffa::MessageField::some(FeatureSet {
806                    enum_type: Some(feature_set::EnumType::CLOSED),
807                    ..Default::default()
808                }),
809                ..Default::default()
810            }),
811            ..Default::default()
812        }
813    }
814
815    fn editions_file(
816        name: &str,
817        package: &str,
818        messages: Vec<DescriptorProto>,
819        enums: Vec<EnumDescriptorProto>,
820    ) -> FileDescriptorProto {
821        use crate::generated::descriptor::Edition;
822        FileDescriptorProto {
823            name: Some(name.to_string()),
824            package: Some(package.to_string()),
825            syntax: Some("editions".to_string()),
826            edition: Some(Edition::EDITION_2023),
827            message_type: messages,
828            enum_type: enums,
829            ..Default::default()
830        }
831    }
832
833    // ── Type registration tests ──────────────────────────────────────────
834
835    #[test]
836    fn test_message_with_package() {
837        let files = [make_file(
838            "test.proto",
839            "my.package",
840            vec![msg("Foo")],
841            vec![],
842        )];
843        let config = CodeGenConfig::default();
844        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
845        assert_eq!(ctx.rust_type(".my.package.Foo"), Some("my::package::Foo"));
846    }
847
848    #[test]
849    fn test_message_no_package() {
850        let files = [make_file("test.proto", "", vec![msg("Bar")], vec![])];
851        let config = CodeGenConfig::default();
852        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
853        assert_eq!(ctx.rust_type(".Bar"), Some("Bar"));
854    }
855
856    #[test]
857    fn test_nested_message_uses_module_path() {
858        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
859        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
860        let config = CodeGenConfig::default();
861        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
862        assert_eq!(ctx.rust_type(".pkg.Outer"), Some("pkg::Outer"));
863        // Nested types use module-qualified paths.
864        assert_eq!(ctx.rust_type(".pkg.Outer.Inner"), Some("pkg::outer::Inner"));
865    }
866
867    #[test]
868    fn test_nested_message_no_package() {
869        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
870        let files = [make_file("test.proto", "", vec![outer], vec![])];
871        let config = CodeGenConfig::default();
872        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
873        assert_eq!(ctx.rust_type(".Outer"), Some("Outer"));
874        assert_eq!(ctx.rust_type(".Outer.Inner"), Some("outer::Inner"));
875    }
876
877    #[test]
878    fn test_deeply_nested_message() {
879        let deep = msg_with_nested("A", vec![msg_with_nested("B", vec![msg("C")])]);
880        let files = [make_file("test.proto", "pkg", vec![deep], vec![])];
881        let config = CodeGenConfig::default();
882        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
883        assert_eq!(ctx.rust_type(".pkg.A"), Some("pkg::A"));
884        assert_eq!(ctx.rust_type(".pkg.A.B"), Some("pkg::a::B"));
885        assert_eq!(ctx.rust_type(".pkg.A.B.C"), Some("pkg::a::b::C"));
886    }
887
888    #[test]
889    fn test_nested_enum_uses_module_path() {
890        let outer = msg_with_nested_and_enums("Outer", vec![], vec![enum_desc("Status")]);
891        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
892        let config = CodeGenConfig::default();
893        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
894        assert_eq!(
895            ctx.rust_type(".pkg.Outer.Status"),
896            Some("pkg::outer::Status")
897        );
898    }
899
900    #[test]
901    fn test_top_level_enum() {
902        let files = [make_file(
903            "test.proto",
904            "pkg",
905            vec![],
906            vec![enum_desc("Status")],
907        )];
908        let config = CodeGenConfig::default();
909        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
910        assert_eq!(ctx.rust_type(".pkg.Status"), Some("pkg::Status"));
911    }
912
913    #[test]
914    fn test_same_named_nested_types_in_different_parents_are_distinct() {
915        let outer1 = msg_with_nested("Outer1", vec![msg("Inner")]);
916        let outer2 = msg_with_nested("Outer2", vec![msg("Inner")]);
917        let files = [make_file("a.proto", "pkg", vec![outer1, outer2], vec![])];
918        let config = CodeGenConfig::default();
919        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
920        // Different parent modules make them distinct.
921        assert_eq!(
922            ctx.rust_type(".pkg.Outer1.Inner"),
923            Some("pkg::outer1::Inner")
924        );
925        assert_eq!(
926            ctx.rust_type(".pkg.Outer2.Inner"),
927            Some("pkg::outer2::Inner")
928        );
929        assert_ne!(
930            ctx.rust_type(".pkg.Outer1.Inner"),
931            ctx.rust_type(".pkg.Outer2.Inner")
932        );
933    }
934
935    #[test]
936    fn test_multiple_files() {
937        let files = [
938            make_file("a.proto", "ns.a", vec![msg("MsgA")], vec![]),
939            make_file("b.proto", "ns.b", vec![msg("MsgB")], vec![]),
940        ];
941        let config = CodeGenConfig::default();
942        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
943        assert_eq!(ctx.rust_type(".ns.a.MsgA"), Some("ns::a::MsgA"));
944        assert_eq!(ctx.rust_type(".ns.b.MsgB"), Some("ns::b::MsgB"));
945    }
946
947    #[test]
948    fn test_keyword_package_segment_in_type_map() {
949        // Proto package `google.type` — the type map stores plain string paths.
950        // Keyword escaping happens at the token level, not in the type map.
951        let files = [make_file(
952            "latlng.proto",
953            "google.type",
954            vec![msg("LatLng")],
955            vec![],
956        )];
957        let config = CodeGenConfig::default();
958        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
959        assert_eq!(
960            ctx.rust_type(".google.type.LatLng"),
961            Some("google::type::LatLng")
962        );
963    }
964
965    #[test]
966    fn test_keyword_package_relative_same_package() {
967        let files = [make_file(
968            "latlng.proto",
969            "google.type",
970            vec![msg("LatLng"), msg("Expr")],
971            vec![],
972        )];
973        let config = CodeGenConfig::default();
974        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
975        // Same-package reference: just the type name (no module prefix).
976        assert_eq!(
977            ctx.rust_type_relative(".google.type.LatLng", "google.type", 0),
978            Some("LatLng".into())
979        );
980    }
981
982    #[test]
983    fn test_keyword_package_cross_package() {
984        let files = [
985            make_file("latlng.proto", "google.type", vec![msg("LatLng")], vec![]),
986            make_file("svc.proto", "google.cloud", vec![msg("Service")], vec![]),
987        ];
988        let config = CodeGenConfig::default();
989        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
990        // Cross-package: relative path via super:: (keyword escaping at token level).
991        // From google.cloud, go up one (past "cloud"), then into "type".
992        assert_eq!(
993            ctx.rust_type_relative(".google.type.LatLng", "google.cloud", 0),
994            Some("super::type::LatLng".into())
995        );
996    }
997
998    #[test]
999    fn test_keyword_nested_message_module() {
1000        // Message named "Type" → module "type" in type map.
1001        let outer = msg_with_nested("Type", vec![msg("Inner")]);
1002        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1003        let config = CodeGenConfig::default();
1004        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1005        assert_eq!(ctx.rust_type(".pkg.Type"), Some("pkg::Type"));
1006        assert_eq!(ctx.rust_type(".pkg.Type.Inner"), Some("pkg::type::Inner"));
1007    }
1008
1009    #[test]
1010    fn test_unknown_type_returns_none() {
1011        let files = [make_file("test.proto", "pkg", vec![msg("Foo")], vec![])];
1012        let config = CodeGenConfig::default();
1013        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1014        assert_eq!(ctx.rust_type(".pkg.Unknown"), None);
1015    }
1016
1017    // ── Relative type resolution tests ───────────────────────────────────
1018
1019    #[test]
1020    fn test_relative_same_package_top_level() {
1021        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
1022        let config = CodeGenConfig::default();
1023        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1024        // From top-level in same package: just the type name.
1025        assert_eq!(
1026            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
1027            Some("Foo".into())
1028        );
1029    }
1030
1031    #[test]
1032    fn test_relative_cross_package() {
1033        let files = [
1034            make_file("a.proto", "pkg_a", vec![msg("Foo")], vec![]),
1035            make_file("b.proto", "pkg_b", vec![msg("Bar")], vec![]),
1036        ];
1037        let config = CodeGenConfig::default();
1038        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1039        // Cross-package: relative via super:: (up one from pkg_b, into pkg_a).
1040        assert_eq!(
1041            ctx.rust_type_relative(".pkg_a.Foo", "pkg_b", 0),
1042            Some("super::pkg_a::Foo".into())
1043        );
1044    }
1045
1046    #[test]
1047    fn test_relative_no_package() {
1048        let files = [make_file("a.proto", "", vec![msg("Foo")], vec![])];
1049        let config = CodeGenConfig::default();
1050        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1051        assert_eq!(ctx.rust_type_relative(".Foo", "", 0), Some("Foo".into()));
1052    }
1053
1054    #[test]
1055    fn test_relative_unknown_returns_none() {
1056        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
1057        let config = CodeGenConfig::default();
1058        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1059        assert_eq!(ctx.rust_type_relative(".pkg.Unknown", "pkg", 0), None);
1060    }
1061
1062    #[test]
1063    fn test_relative_dotted_package() {
1064        let files = [make_file("a.proto", "my.pkg", vec![msg("Foo")], vec![])];
1065        let config = CodeGenConfig::default();
1066        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1067        assert_eq!(
1068            ctx.rust_type_relative(".my.pkg.Foo", "my.pkg", 0),
1069            Some("Foo".into())
1070        );
1071    }
1072
1073    #[test]
1074    fn test_relative_cross_dotted_packages() {
1075        let files = [
1076            make_file(
1077                "timestamp.proto",
1078                "google.protobuf",
1079                vec![msg("Timestamp")],
1080                vec![],
1081            ),
1082            make_file(
1083                "test.proto",
1084                "protobuf_test_messages.proto3",
1085                vec![msg("TestAllTypesProto3")],
1086                vec![],
1087            ),
1088        ];
1089        let config = CodeGenConfig::default();
1090        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1091
1092        // Cross-package: relative via super:: (no common prefix, up 2 levels).
1093        assert_eq!(
1094            ctx.rust_type_relative(
1095                ".google.protobuf.Timestamp",
1096                "protobuf_test_messages.proto3",
1097                0,
1098            ),
1099            Some("super::super::google::protobuf::Timestamp".into())
1100        );
1101    }
1102
1103    #[test]
1104    fn test_relative_nested_type_from_same_package() {
1105        // Referencing Outer.Inner from the same package.
1106        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1107        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1108        let config = CodeGenConfig::default();
1109        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1110
1111        // Same package: strips the package prefix, keeps module path.
1112        assert_eq!(
1113            ctx.rust_type_relative(".pkg.Outer.Inner", "pkg", 0),
1114            Some("outer::Inner".into())
1115        );
1116    }
1117
1118    #[test]
1119    fn test_relative_shared_prefix_not_confused() {
1120        let files = [
1121            make_file("ab.proto", "a.b", vec![msg("Msg1")], vec![]),
1122            make_file("abc.proto", "a.bc", vec![msg("Msg2")], vec![]),
1123        ];
1124        let config = CodeGenConfig::default();
1125        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1126
1127        // `a.b.Msg1` from `a.bc` context: common prefix "a", up 1, into "b".
1128        assert_eq!(
1129            ctx.rust_type_relative(".a.b.Msg1", "a.bc", 0),
1130            Some("super::b::Msg1".into())
1131        );
1132        // `a.bc.Msg2` from `a.b` context: common prefix "a", up 1, into "bc".
1133        assert_eq!(
1134            ctx.rust_type_relative(".a.bc.Msg2", "a.b", 0),
1135            Some("super::bc::Msg2".into())
1136        );
1137    }
1138
1139    // ── Nesting depth tests ────────────────────────────────────────────
1140
1141    #[test]
1142    fn test_relative_cross_package_nesting_1() {
1143        // Simulates a nested message (inside a `pub mod`) referencing a type
1144        // from a sibling package. E.g., account.business.admin.v1 nested msg
1145        // referencing account.business.v1.Business.Status.
1146        let outer = msg_with_nested_and_enums("Business", vec![], vec![enum_desc("Status")]);
1147        let files = [
1148            make_file("admin.proto", "a.b.admin.v1", vec![msg("Svc")], vec![]),
1149            make_file("biz.proto", "a.b.v1", vec![outer], vec![]),
1150        ];
1151        let config = CodeGenConfig::default();
1152        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1153
1154        // nesting=0 (top-level struct in admin.v1): up 2 (v1→admin), into v1
1155        assert_eq!(
1156            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 0),
1157            Some("super::super::v1::business::Status".into())
1158        );
1159        // nesting=1 (inside a nested message module): one extra super::
1160        assert_eq!(
1161            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 1),
1162            Some("super::super::super::v1::business::Status".into())
1163        );
1164    }
1165
1166    #[test]
1167    fn test_relative_same_package_nesting_1() {
1168        // Nested message referencing a sibling type in the same package.
1169        let files = [make_file(
1170            "test.proto",
1171            "pkg",
1172            vec![msg("Foo"), msg("Bar")],
1173            vec![],
1174        )];
1175        let config = CodeGenConfig::default();
1176        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1177
1178        // nesting=0: same package, just the name
1179        assert_eq!(
1180            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
1181            Some("Foo".into())
1182        );
1183        // nesting=1: inside a message module, needs one super::
1184        assert_eq!(
1185            ctx.rust_type_relative(".pkg.Foo", "pkg", 1),
1186            Some("super::Foo".into())
1187        );
1188        // nesting=2: doubly nested
1189        assert_eq!(
1190            ctx.rust_type_relative(".pkg.Foo", "pkg", 2),
1191            Some("super::super::Foo".into())
1192        );
1193    }
1194
1195    // ── Extern path tests ─────────────────────────────────────────────
1196
1197    #[test]
1198    fn test_resolve_extern_prefix_exact_match() {
1199        let result = resolve_extern_prefix(
1200            "my.common",
1201            &[(".my.common".into(), "::common_protos".into())],
1202        );
1203        assert_eq!(result, Some("::common_protos".into()));
1204    }
1205
1206    #[test]
1207    fn test_resolve_extern_prefix_sub_package() {
1208        let result = resolve_extern_prefix(
1209            "my.common.sub",
1210            &[(".my.common".into(), "::common_protos".into())],
1211        );
1212        assert_eq!(result, Some("::common_protos::sub".into()));
1213    }
1214
1215    #[test]
1216    fn test_resolve_extern_prefix_no_match() {
1217        let result = resolve_extern_prefix(
1218            "other.pkg",
1219            &[(".my.common".into(), "::common_protos".into())],
1220        );
1221        assert_eq!(result, None);
1222    }
1223
1224    #[test]
1225    fn test_resolve_extern_prefix_partial_name_no_match() {
1226        // ".my.common" should not match ".my.commonext"
1227        let result = resolve_extern_prefix(
1228            "my.commonext",
1229            &[(".my.common".into(), "::common_protos".into())],
1230        );
1231        assert_eq!(result, None);
1232    }
1233
1234    #[test]
1235    fn test_resolve_extern_prefix_longest_match_wins() {
1236        // When multiple prefixes match, the longest one should win.
1237        let result = resolve_extern_prefix(
1238            "my.common.sub",
1239            &[
1240                (".my".into(), "::crate_a".into()),
1241                (".my.common".into(), "::crate_b".into()),
1242            ],
1243        );
1244        assert_eq!(result, Some("::crate_b::sub".into()));
1245    }
1246
1247    #[test]
1248    fn test_resolve_extern_prefix_catchall() {
1249        let result = resolve_extern_prefix("greet.v1", &[(".".into(), "crate::proto".into())]);
1250        assert_eq!(result, Some("crate::proto::greet::v1".into()));
1251    }
1252
1253    #[test]
1254    fn test_resolve_extern_prefix_catchall_empty_pkg() {
1255        // Empty package with `.` catch-all hits the exact-match branch
1256        // (dotted == "." == proto_prefix) and returns the root as-is.
1257        let result = resolve_extern_prefix("", &[(".".into(), "crate::proto".into())]);
1258        assert_eq!(result, Some("crate::proto".into()));
1259    }
1260
1261    #[test]
1262    fn test_resolve_extern_prefix_catchall_longest_wins() {
1263        // `.` catch-all is the shortest possible prefix; any more-specific
1264        // mapping (including the auto-injected WKT mapping) takes priority.
1265        let result = resolve_extern_prefix(
1266            "google.protobuf",
1267            &[
1268                (".".into(), "crate::proto".into()),
1269                (
1270                    ".google.protobuf".into(),
1271                    "::buffa_types::google::protobuf".into(),
1272                ),
1273            ],
1274        );
1275        assert_eq!(result, Some("::buffa_types::google::protobuf".into()));
1276    }
1277
1278    #[test]
1279    fn test_resolve_extern_prefix_catchall_keyword_package() {
1280        // Keyword segments stay unescaped at the string level; escaping to
1281        // `r#type` happens later in `idents::rust_path_to_tokens`.
1282        let result = resolve_extern_prefix("google.type", &[(".".into(), "crate::proto".into())]);
1283        assert_eq!(result, Some("crate::proto::google::type".into()));
1284    }
1285
1286    // ── rust_type_relative_split — extern branch ────────────────────────
1287
1288    #[test]
1289    fn test_split_extern_top_level() {
1290        let outer = msg_with_nested("Value", vec![msg("Inner")]);
1291        let files = [make_file(
1292            "struct.proto",
1293            "google.protobuf",
1294            vec![outer],
1295            vec![],
1296        )];
1297        let config = CodeGenConfig::default();
1298        let extern_paths = vec![(
1299            ".google.protobuf".into(),
1300            "::buffa_types::google::protobuf".into(),
1301        )];
1302        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
1303
1304        let split = ctx
1305            .rust_type_relative_split(".google.protobuf.Value", "my.pkg", 3)
1306            .expect("type resolves");
1307        assert!(split.is_extern);
1308        // Extern path is absolute → nesting irrelevant.
1309        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
1310        assert_eq!(split.within_package, "Value");
1311    }
1312
1313    #[test]
1314    fn test_split_extern_nested_type() {
1315        // Nested `.google.protobuf.Value.Inner` →
1316        // extern path `::buffa_types::google::protobuf::value::Inner`.
1317        // Segment-count slice: 2 within-package segments → cut after the
1318        // extern module prefix.
1319        let outer = msg_with_nested("Value", vec![msg("Inner")]);
1320        let files = [make_file(
1321            "struct.proto",
1322            "google.protobuf",
1323            vec![outer],
1324            vec![],
1325        )];
1326        let config = CodeGenConfig::default();
1327        let extern_paths = vec![(
1328            ".google.protobuf".into(),
1329            "::buffa_types::google::protobuf".into(),
1330        )];
1331        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
1332
1333        let split = ctx
1334            .rust_type_relative_split(".google.protobuf.Value.Inner", "my.pkg", 0)
1335            .expect("nested type resolves");
1336        assert!(split.is_extern);
1337        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
1338        assert_eq!(split.within_package, "value::Inner");
1339    }
1340
1341    #[test]
1342    fn test_extern_path_top_level_message() {
1343        let files = [make_file(
1344            "common.proto",
1345            "my.common",
1346            vec![msg("SharedMsg")],
1347            vec![],
1348        )];
1349        let config = CodeGenConfig {
1350            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1351            ..Default::default()
1352        };
1353        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1354        assert_eq!(
1355            ctx.rust_type(".my.common.SharedMsg"),
1356            Some("::common_protos::SharedMsg")
1357        );
1358    }
1359
1360    #[test]
1361    fn test_extern_path_nested_message() {
1362        let files = [make_file(
1363            "common.proto",
1364            "my.common",
1365            vec![msg_with_nested("Outer", vec![msg("Inner")])],
1366            vec![],
1367        )];
1368        let config = CodeGenConfig {
1369            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1370            ..Default::default()
1371        };
1372        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1373        assert_eq!(
1374            ctx.rust_type(".my.common.Outer"),
1375            Some("::common_protos::Outer")
1376        );
1377        assert_eq!(
1378            ctx.rust_type(".my.common.Outer.Inner"),
1379            Some("::common_protos::outer::Inner")
1380        );
1381    }
1382
1383    #[test]
1384    fn test_extern_path_enum() {
1385        let files = [make_file(
1386            "common.proto",
1387            "my.common",
1388            vec![],
1389            vec![enum_desc("Status")],
1390        )];
1391        let config = CodeGenConfig {
1392            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1393            ..Default::default()
1394        };
1395        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1396        assert_eq!(
1397            ctx.rust_type(".my.common.Status"),
1398            Some("::common_protos::Status")
1399        );
1400    }
1401
1402    #[test]
1403    fn test_extern_path_does_not_affect_other_packages() {
1404        let files = [
1405            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
1406            make_file(
1407                "service.proto",
1408                "my.service",
1409                vec![msg("MyService")],
1410                vec![],
1411            ),
1412        ];
1413        let config = CodeGenConfig {
1414            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1415            ..Default::default()
1416        };
1417        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1418        // Extern type uses absolute path.
1419        assert_eq!(
1420            ctx.rust_type(".my.common.SharedMsg"),
1421            Some("::common_protos::SharedMsg")
1422        );
1423        // Non-extern type uses normal package-derived path.
1424        assert_eq!(
1425            ctx.rust_type(".my.service.MyService"),
1426            Some("my::service::MyService")
1427        );
1428    }
1429
1430    #[test]
1431    fn test_extern_path_relative_returns_absolute() {
1432        // When an extern type is referenced from another package,
1433        // rust_type_relative should return the full absolute path.
1434        let files = [
1435            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
1436            make_file(
1437                "service.proto",
1438                "my.service",
1439                vec![msg("MyService")],
1440                vec![],
1441            ),
1442        ];
1443        let config = CodeGenConfig {
1444            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1445            ..Default::default()
1446        };
1447        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1448        // Cross-package reference to extern type: absolute path.
1449        assert_eq!(
1450            ctx.rust_type_relative(".my.common.SharedMsg", "my.service", 0),
1451            Some("::common_protos::SharedMsg".into())
1452        );
1453    }
1454
1455    // ── is_enum_closed tests ──────────────────────────────────────────────
1456
1457    #[test]
1458    fn test_is_enum_closed_proto3_default_open() {
1459        let files = [make_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1460        let config = CodeGenConfig::default();
1461        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1462        // proto3 default (make_file has no syntax = proto2/implicit)
1463        // actually make_file doesn't set syntax, so it's proto2 default...
1464        // proto2 default is CLOSED.
1465        assert_eq!(ctx.is_enum_closed(".p.E"), Some(true));
1466    }
1467
1468    #[test]
1469    fn test_is_enum_closed_editions_default_open() {
1470        let files = [editions_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1471        let config = CodeGenConfig::default();
1472        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1473        // Edition 2023 default is OPEN.
1474        assert_eq!(ctx.is_enum_closed(".p.E"), Some(false));
1475    }
1476
1477    #[test]
1478    fn test_is_enum_closed_per_enum_override() {
1479        // This is THE bug: enum with `option features.enum_type = CLOSED`
1480        // in an otherwise-open editions file must be detected as closed.
1481        let files = [editions_file(
1482            "a.proto",
1483            "p",
1484            vec![],
1485            vec![enum_desc("Open"), enum_with_closed_feature("Closed")],
1486        )];
1487        let config = CodeGenConfig::default();
1488        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1489        assert_eq!(ctx.is_enum_closed(".p.Open"), Some(false));
1490        assert_eq!(ctx.is_enum_closed(".p.Closed"), Some(true));
1491    }
1492
1493    #[test]
1494    fn test_is_enum_closed_nested_per_enum_override() {
1495        // Feature resolution through file → message → enum.
1496        let files = [editions_file(
1497            "a.proto",
1498            "p",
1499            vec![msg_with_nested_and_enums(
1500                "M",
1501                vec![],
1502                vec![enum_with_closed_feature("Inner")],
1503            )],
1504            vec![],
1505        )];
1506        let config = CodeGenConfig::default();
1507        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1508        assert_eq!(ctx.is_enum_closed(".p.M.Inner"), Some(true));
1509    }
1510
1511    #[test]
1512    fn test_is_enum_closed_unknown_enum_returns_none() {
1513        let files = [editions_file("a.proto", "p", vec![], vec![])];
1514        let config = CodeGenConfig::default();
1515        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1516        // extern_path or missing enum → None (caller falls back).
1517        assert_eq!(ctx.is_enum_closed(".other.Unknown"), None);
1518    }
1519
1520    #[test]
1521    fn test_for_generate_auto_injects_wkt_mapping() {
1522        // for_generate() must produce the same type_map as generate() uses
1523        // internally — including the auto-injected WKT extern_path.
1524        let ts_msg = DescriptorProto {
1525            name: Some("Timestamp".into()),
1526            ..Default::default()
1527        };
1528        let files = [FileDescriptorProto {
1529            name: Some("google/protobuf/timestamp.proto".into()),
1530            package: Some("google.protobuf".into()),
1531            syntax: Some("proto3".into()),
1532            message_type: vec![ts_msg],
1533            ..Default::default()
1534        }];
1535        let config = CodeGenConfig::default();
1536        // Not generating the WKT file itself → auto-mapping should kick in.
1537        let ctx = CodeGenContext::for_generate(&files, &["other.proto".into()], &config);
1538        assert_eq!(
1539            ctx.rust_type(".google.protobuf.Timestamp"),
1540            Some("::buffa_types::google::protobuf::Timestamp"),
1541            "WKT auto-mapping must be applied via for_generate"
1542        );
1543    }
1544
1545    #[test]
1546    fn test_for_generate_suppresses_wkt_when_generating_wkt() {
1547        // When files_to_generate includes a google.protobuf file (building
1548        // buffa-types itself), the WKT auto-mapping must NOT be applied.
1549        let ts_msg = DescriptorProto {
1550            name: Some("Timestamp".into()),
1551            ..Default::default()
1552        };
1553        let files = [FileDescriptorProto {
1554            name: Some("google/protobuf/timestamp.proto".into()),
1555            package: Some("google.protobuf".into()),
1556            syntax: Some("proto3".into()),
1557            message_type: vec![ts_msg],
1558            ..Default::default()
1559        }];
1560        let config = CodeGenConfig::default();
1561        let ctx = CodeGenContext::for_generate(
1562            &files,
1563            &["google/protobuf/timestamp.proto".into()],
1564            &config,
1565        );
1566        // No extern mapping → local-package path.
1567        assert_eq!(
1568            ctx.rust_type(".google.protobuf.Timestamp"),
1569            Some("google::protobuf::Timestamp")
1570        );
1571    }
1572
1573    // ── matching_attributes tests ──────────────────────────────────────
1574
1575    #[test]
1576    fn test_matching_attributes_catchall() {
1577        // "." matches every type.
1578        let attrs = vec![(".".into(), "#[derive(Foo)]".into())];
1579        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1580        assert!(result.to_string().contains("derive"));
1581    }
1582
1583    #[test]
1584    fn test_matching_attributes_exact_match() {
1585        let attrs = vec![(".my.pkg.MyMessage".into(), "#[derive(Bar)]".into())];
1586        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1587        assert!(result.to_string().contains("derive"));
1588    }
1589
1590    #[test]
1591    fn test_matching_attributes_package_prefix() {
1592        let attrs = vec![(".my.pkg".into(), "#[derive(Baz)]".into())];
1593        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1594        assert!(result.to_string().contains("derive"));
1595    }
1596
1597    #[test]
1598    fn test_matching_attributes_no_partial_segment_match() {
1599        // ".my.pk" must not match ".my.pkg" (partial segment).
1600        let attrs = vec![(".my.pk".into(), "#[derive(Bad)]".into())];
1601        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1602        assert!(result.is_empty());
1603    }
1604
1605    #[test]
1606    fn test_matching_attributes_no_match() {
1607        let attrs = vec![(".other.pkg".into(), "#[derive(Nope)]".into())];
1608        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1609        assert!(result.is_empty());
1610    }
1611
1612    #[test]
1613    fn test_matching_attributes_multiple_accumulate() {
1614        // All matching entries are emitted, not just the first.
1615        let attrs = vec![
1616            (".".into(), "#[derive(A)]".into()),
1617            (".my.pkg".into(), "#[derive(B)]".into()),
1618        ];
1619        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1620        let s = result.to_string();
1621        assert!(s.contains("A") && s.contains("B"));
1622    }
1623
1624    #[test]
1625    fn test_matching_attributes_invalid_attr_errors() {
1626        // Unparseable attributes surface as a hard error so the user sees
1627        // the problem at build time rather than a silently-dropped attribute.
1628        let attrs = vec![(".".into(), "not valid {{{{".into())];
1629        let err = CodeGenContext::matching_attributes(&attrs, "my.pkg.Msg").unwrap_err();
1630        assert!(matches!(
1631            err,
1632            crate::CodeGenError::InvalidCustomAttribute { .. }
1633        ));
1634    }
1635
1636    #[test]
1637    fn test_matches_proto_prefix_catchall() {
1638        assert!(matches_proto_prefix(".", ".anything.here"));
1639        assert!(matches_proto_prefix(".", "."));
1640    }
1641
1642    #[test]
1643    fn test_matches_proto_prefix_segment_boundary() {
1644        // Segment-aware: ".my.pk" must not match ".my.pkg".
1645        assert!(!matches_proto_prefix(".my.pk", ".my.pkg.Msg"));
1646        // But full-segment prefix match does.
1647        assert!(matches_proto_prefix(".my.pkg", ".my.pkg.Msg"));
1648    }
1649}