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/// [`crate::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::oneof::<msg_path>::` — view oneof enums.
547    ViewOneof,
548}
549
550impl AncillaryKind {
551    fn path_segments(self) -> &'static [&'static str] {
552        match self {
553            Self::Oneof => &["oneof"],
554            Self::ViewOneof => &["view", "oneof"],
555        }
556    }
557}
558
559/// Build a token-stream path prefix from an emission scope to an ancillary
560/// kind's location for the **current** message (`proto_fqn`).
561///
562/// Always climbs to the package root via `super::` and re-descends through
563/// `__buffa::<kind>::<msg_path>::` — uniform regardless of where the caller
564/// sits. `from_nesting` is the caller's total module depth below the
565/// package root (message-nesting plus any `__buffa::<kind>::` levels the
566/// caller is already inside).
567///
568/// `proto_fqn` follows the dotless convention used throughout codegen
569/// (e.g. `"google.protobuf.Value"`, not `".google.protobuf.Value"`).
570///
571/// Returned tokens always end with `::` so callers append the type
572/// identifier directly: `quote! { #prefix #ident }`.
573pub(crate) fn ancillary_prefix(
574    kind: AncillaryKind,
575    current_package: &str,
576    proto_fqn: &str,
577    from_nesting: usize,
578) -> proc_macro2::TokenStream {
579    use crate::idents::make_field_ident;
580    use quote::quote;
581
582    debug_assert!(
583        !proto_fqn.starts_with('.'),
584        "ancillary_prefix expects dotless FQN, got {proto_fqn:?}"
585    );
586
587    let mut supers_tokens = proc_macro2::TokenStream::new();
588    for _ in 0..from_nesting {
589        supers_tokens.extend(quote! { super:: });
590    }
591
592    let sentinel = make_field_ident(SENTINEL_MOD);
593    let kind_segs: Vec<_> = kind
594        .path_segments()
595        .iter()
596        .map(|s| make_field_ident(s))
597        .collect();
598
599    // Snake-cased message path within the package (e.g. "outer::inner::").
600    let within_pkg = if current_package.is_empty() {
601        proto_fqn
602    } else {
603        proto_fqn
604            .strip_prefix(current_package)
605            .and_then(|s| s.strip_prefix('.'))
606            .unwrap_or(proto_fqn)
607    };
608    let msg_segs: Vec<_> = within_pkg
609        .split('.')
610        .filter(|s| !s.is_empty())
611        .map(|name| make_field_ident(&to_snake_case(name)))
612        .collect();
613
614    quote! { #supers_tokens #sentinel :: #(#kind_segs ::)* #(#msg_segs ::)* }
615}
616
617/// Proto-segment-aware prefix match: `prefix` matches `fqn_dotted` if
618/// `prefix == "."`, the two are equal, or `fqn_dotted` starts with `prefix`
619/// followed by a `.` boundary. Proto identifiers are ASCII, and `.` is ASCII,
620/// so byte indexing is safe.
621pub(crate) fn matches_proto_prefix(prefix: &str, fqn_dotted: &str) -> bool {
622    prefix == "."
623        || prefix == fqn_dotted
624        || (fqn_dotted.starts_with(prefix)
625            && fqn_dotted.as_bytes().get(prefix.len()) == Some(&b'.'))
626}
627
628/// Check if a proto package matches any extern_path prefix.
629///
630/// Returns the Rust module path root if matched, including any remaining
631/// package segments converted to `snake_case` modules. For example,
632/// extern_path `(".my", "::my_crate")` with package `"my.sub.pkg"` returns
633/// `"::my_crate::sub::pkg"`.
634fn resolve_extern_prefix(package: &str, extern_paths: &[(String, String)]) -> Option<String> {
635    let dotted = format!(".{}", package);
636
637    // Try longest prefix first so that more specific mappings take priority
638    // over broader ones (e.g., ".my.common" before ".my").
639    let mut best: Option<(&str, &str, usize)> = None;
640
641    for (proto_prefix, rust_prefix) in extern_paths {
642        if dotted == *proto_prefix {
643            // Exact match is always the best.
644            return Some(rust_prefix.clone());
645        }
646        if let Some(rest) = dotted.strip_prefix(proto_prefix.as_str()) {
647            // `"."` is the catch-all root; stripping it leaves no leading dot.
648            if proto_prefix == "." || rest.starts_with('.') {
649                let prefix_len = proto_prefix.len();
650                if best.is_none_or(|(_, _, best_len)| prefix_len > best_len) {
651                    best = Some((proto_prefix, rust_prefix, prefix_len));
652                }
653            }
654        }
655    }
656
657    let (proto_prefix, rust_prefix, _) = best?;
658    let rest = dotted.strip_prefix(proto_prefix)?;
659    let rest = rest.strip_prefix('.').unwrap_or(rest);
660    let suffix = rest
661        .split('.')
662        .map(to_snake_case)
663        .collect::<Vec<_>>()
664        .join("::");
665    Some(format!("{}::{}", rust_prefix, suffix))
666}
667
668/// Recursively register nested messages and enums with module-qualified paths.
669///
670/// Each nested message `Parent.Child` maps to `parent_mod::Child` in Rust,
671/// where `parent_mod` is the snake_case module path of the enclosing message.
672fn register_nested_types(
673    type_map: &mut HashMap<String, String>,
674    package_of: &mut HashMap<String, String>,
675    package: &str,
676    parent_fqn: &str,
677    parent_mod: &str,
678    msg: &crate::generated::descriptor::DescriptorProto,
679) {
680    for nested in &msg.nested_type {
681        if let Some(name) = &nested.name {
682            let fqn = format!("{}.{}", parent_fqn, name);
683            let rust_path = format!("{}::{}", parent_mod, name);
684            type_map.insert(fqn.clone(), rust_path);
685            package_of.insert(fqn.clone(), package.to_string());
686
687            // Recurse: nested-of-nested goes in a deeper module.
688            let child_mod = format!("{}::{}", parent_mod, to_snake_case(name));
689            register_nested_types(type_map, package_of, package, &fqn, &child_mod, nested);
690        }
691    }
692
693    for enum_type in &msg.enum_type {
694        if let Some(name) = &enum_type.name {
695            let fqn = format!("{}.{}", parent_fqn, name);
696            let rust_path = format!("{}::{}", parent_mod, name);
697            type_map.insert(fqn.clone(), rust_path);
698            package_of.insert(fqn, package.to_string());
699        }
700    }
701}
702
703/// Resolve and record whether an enum is closed, given its parent's features.
704fn register_enum_closedness(
705    map: &mut HashMap<String, bool>,
706    fqn: &str,
707    parent_features: &ResolvedFeatures,
708    enum_desc: &EnumDescriptorProto,
709) {
710    let resolved = features::resolve_child(parent_features, features::enum_features(enum_desc));
711    let closed = resolved.enum_type == features::EnumType::Closed;
712    map.insert(fqn.to_string(), closed);
713}
714
715/// Walk nested messages and register all enum closedness, resolving features
716/// through the message hierarchy (file → msg → nested_msg → enum).
717fn register_nested_enum_closedness(
718    map: &mut HashMap<String, bool>,
719    parent_fqn: &str,
720    parent_features: &ResolvedFeatures,
721    msg: &DescriptorProto,
722) {
723    let msg_features = features::resolve_child(parent_features, features::message_features(msg));
724    for enum_type in &msg.enum_type {
725        if let Some(name) = &enum_type.name {
726            let fqn = format!("{}.{}", parent_fqn, name);
727            register_enum_closedness(map, &fqn, &msg_features, enum_type);
728        }
729    }
730    for nested in &msg.nested_type {
731        if let Some(name) = &nested.name {
732            let fqn = format!("{}.{}", parent_fqn, name);
733            register_nested_enum_closedness(map, &fqn, &msg_features, nested);
734        }
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
742
743    fn make_file(
744        name: &str,
745        package: &str,
746        messages: Vec<DescriptorProto>,
747        enums: Vec<EnumDescriptorProto>,
748    ) -> FileDescriptorProto {
749        FileDescriptorProto {
750            name: Some(name.to_string()),
751            package: if package.is_empty() {
752                None
753            } else {
754                Some(package.to_string())
755            },
756            message_type: messages,
757            enum_type: enums,
758            ..Default::default()
759        }
760    }
761
762    fn msg(name: &str) -> DescriptorProto {
763        DescriptorProto {
764            name: Some(name.to_string()),
765            ..Default::default()
766        }
767    }
768
769    fn msg_with_nested(name: &str, nested: Vec<DescriptorProto>) -> DescriptorProto {
770        DescriptorProto {
771            name: Some(name.to_string()),
772            nested_type: nested,
773            ..Default::default()
774        }
775    }
776
777    fn msg_with_nested_and_enums(
778        name: &str,
779        nested: Vec<DescriptorProto>,
780        enums: Vec<EnumDescriptorProto>,
781    ) -> DescriptorProto {
782        DescriptorProto {
783            name: Some(name.to_string()),
784            nested_type: nested,
785            enum_type: enums,
786            ..Default::default()
787        }
788    }
789
790    fn enum_desc(name: &str) -> EnumDescriptorProto {
791        EnumDescriptorProto {
792            name: Some(name.to_string()),
793            ..Default::default()
794        }
795    }
796
797    fn enum_with_closed_feature(name: &str) -> EnumDescriptorProto {
798        use crate::generated::descriptor::{feature_set, EnumOptions, FeatureSet};
799        EnumDescriptorProto {
800            name: Some(name.to_string()),
801            options: buffa::MessageField::some(EnumOptions {
802                features: buffa::MessageField::some(FeatureSet {
803                    enum_type: Some(feature_set::EnumType::CLOSED),
804                    ..Default::default()
805                }),
806                ..Default::default()
807            }),
808            ..Default::default()
809        }
810    }
811
812    fn editions_file(
813        name: &str,
814        package: &str,
815        messages: Vec<DescriptorProto>,
816        enums: Vec<EnumDescriptorProto>,
817    ) -> FileDescriptorProto {
818        use crate::generated::descriptor::Edition;
819        FileDescriptorProto {
820            name: Some(name.to_string()),
821            package: Some(package.to_string()),
822            syntax: Some("editions".to_string()),
823            edition: Some(Edition::EDITION_2023),
824            message_type: messages,
825            enum_type: enums,
826            ..Default::default()
827        }
828    }
829
830    // ── Type registration tests ──────────────────────────────────────────
831
832    #[test]
833    fn test_message_with_package() {
834        let files = [make_file(
835            "test.proto",
836            "my.package",
837            vec![msg("Foo")],
838            vec![],
839        )];
840        let config = CodeGenConfig::default();
841        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
842        assert_eq!(ctx.rust_type(".my.package.Foo"), Some("my::package::Foo"));
843    }
844
845    #[test]
846    fn test_message_no_package() {
847        let files = [make_file("test.proto", "", vec![msg("Bar")], vec![])];
848        let config = CodeGenConfig::default();
849        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
850        assert_eq!(ctx.rust_type(".Bar"), Some("Bar"));
851    }
852
853    #[test]
854    fn test_nested_message_uses_module_path() {
855        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
856        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
857        let config = CodeGenConfig::default();
858        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
859        assert_eq!(ctx.rust_type(".pkg.Outer"), Some("pkg::Outer"));
860        // Nested types use module-qualified paths.
861        assert_eq!(ctx.rust_type(".pkg.Outer.Inner"), Some("pkg::outer::Inner"));
862    }
863
864    #[test]
865    fn test_nested_message_no_package() {
866        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
867        let files = [make_file("test.proto", "", vec![outer], vec![])];
868        let config = CodeGenConfig::default();
869        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
870        assert_eq!(ctx.rust_type(".Outer"), Some("Outer"));
871        assert_eq!(ctx.rust_type(".Outer.Inner"), Some("outer::Inner"));
872    }
873
874    #[test]
875    fn test_deeply_nested_message() {
876        let deep = msg_with_nested("A", vec![msg_with_nested("B", vec![msg("C")])]);
877        let files = [make_file("test.proto", "pkg", vec![deep], vec![])];
878        let config = CodeGenConfig::default();
879        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
880        assert_eq!(ctx.rust_type(".pkg.A"), Some("pkg::A"));
881        assert_eq!(ctx.rust_type(".pkg.A.B"), Some("pkg::a::B"));
882        assert_eq!(ctx.rust_type(".pkg.A.B.C"), Some("pkg::a::b::C"));
883    }
884
885    #[test]
886    fn test_nested_enum_uses_module_path() {
887        let outer = msg_with_nested_and_enums("Outer", vec![], vec![enum_desc("Status")]);
888        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
889        let config = CodeGenConfig::default();
890        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
891        assert_eq!(
892            ctx.rust_type(".pkg.Outer.Status"),
893            Some("pkg::outer::Status")
894        );
895    }
896
897    #[test]
898    fn test_top_level_enum() {
899        let files = [make_file(
900            "test.proto",
901            "pkg",
902            vec![],
903            vec![enum_desc("Status")],
904        )];
905        let config = CodeGenConfig::default();
906        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
907        assert_eq!(ctx.rust_type(".pkg.Status"), Some("pkg::Status"));
908    }
909
910    #[test]
911    fn test_same_named_nested_types_in_different_parents_are_distinct() {
912        let outer1 = msg_with_nested("Outer1", vec![msg("Inner")]);
913        let outer2 = msg_with_nested("Outer2", vec![msg("Inner")]);
914        let files = [make_file("a.proto", "pkg", vec![outer1, outer2], vec![])];
915        let config = CodeGenConfig::default();
916        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
917        // Different parent modules make them distinct.
918        assert_eq!(
919            ctx.rust_type(".pkg.Outer1.Inner"),
920            Some("pkg::outer1::Inner")
921        );
922        assert_eq!(
923            ctx.rust_type(".pkg.Outer2.Inner"),
924            Some("pkg::outer2::Inner")
925        );
926        assert_ne!(
927            ctx.rust_type(".pkg.Outer1.Inner"),
928            ctx.rust_type(".pkg.Outer2.Inner")
929        );
930    }
931
932    #[test]
933    fn test_multiple_files() {
934        let files = [
935            make_file("a.proto", "ns.a", vec![msg("MsgA")], vec![]),
936            make_file("b.proto", "ns.b", vec![msg("MsgB")], vec![]),
937        ];
938        let config = CodeGenConfig::default();
939        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
940        assert_eq!(ctx.rust_type(".ns.a.MsgA"), Some("ns::a::MsgA"));
941        assert_eq!(ctx.rust_type(".ns.b.MsgB"), Some("ns::b::MsgB"));
942    }
943
944    #[test]
945    fn test_keyword_package_segment_in_type_map() {
946        // Proto package `google.type` — the type map stores plain string paths.
947        // Keyword escaping happens at the token level, not in the type map.
948        let files = [make_file(
949            "latlng.proto",
950            "google.type",
951            vec![msg("LatLng")],
952            vec![],
953        )];
954        let config = CodeGenConfig::default();
955        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
956        assert_eq!(
957            ctx.rust_type(".google.type.LatLng"),
958            Some("google::type::LatLng")
959        );
960    }
961
962    #[test]
963    fn test_keyword_package_relative_same_package() {
964        let files = [make_file(
965            "latlng.proto",
966            "google.type",
967            vec![msg("LatLng"), msg("Expr")],
968            vec![],
969        )];
970        let config = CodeGenConfig::default();
971        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
972        // Same-package reference: just the type name (no module prefix).
973        assert_eq!(
974            ctx.rust_type_relative(".google.type.LatLng", "google.type", 0),
975            Some("LatLng".into())
976        );
977    }
978
979    #[test]
980    fn test_keyword_package_cross_package() {
981        let files = [
982            make_file("latlng.proto", "google.type", vec![msg("LatLng")], vec![]),
983            make_file("svc.proto", "google.cloud", vec![msg("Service")], vec![]),
984        ];
985        let config = CodeGenConfig::default();
986        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
987        // Cross-package: relative path via super:: (keyword escaping at token level).
988        // From google.cloud, go up one (past "cloud"), then into "type".
989        assert_eq!(
990            ctx.rust_type_relative(".google.type.LatLng", "google.cloud", 0),
991            Some("super::type::LatLng".into())
992        );
993    }
994
995    #[test]
996    fn test_keyword_nested_message_module() {
997        // Message named "Type" → module "type" in type map.
998        let outer = msg_with_nested("Type", vec![msg("Inner")]);
999        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1000        let config = CodeGenConfig::default();
1001        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1002        assert_eq!(ctx.rust_type(".pkg.Type"), Some("pkg::Type"));
1003        assert_eq!(ctx.rust_type(".pkg.Type.Inner"), Some("pkg::type::Inner"));
1004    }
1005
1006    #[test]
1007    fn test_unknown_type_returns_none() {
1008        let files = [make_file("test.proto", "pkg", vec![msg("Foo")], vec![])];
1009        let config = CodeGenConfig::default();
1010        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1011        assert_eq!(ctx.rust_type(".pkg.Unknown"), None);
1012    }
1013
1014    // ── Relative type resolution tests ───────────────────────────────────
1015
1016    #[test]
1017    fn test_relative_same_package_top_level() {
1018        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
1019        let config = CodeGenConfig::default();
1020        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1021        // From top-level in same package: just the type name.
1022        assert_eq!(
1023            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
1024            Some("Foo".into())
1025        );
1026    }
1027
1028    #[test]
1029    fn test_relative_cross_package() {
1030        let files = [
1031            make_file("a.proto", "pkg_a", vec![msg("Foo")], vec![]),
1032            make_file("b.proto", "pkg_b", vec![msg("Bar")], vec![]),
1033        ];
1034        let config = CodeGenConfig::default();
1035        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1036        // Cross-package: relative via super:: (up one from pkg_b, into pkg_a).
1037        assert_eq!(
1038            ctx.rust_type_relative(".pkg_a.Foo", "pkg_b", 0),
1039            Some("super::pkg_a::Foo".into())
1040        );
1041    }
1042
1043    #[test]
1044    fn test_relative_no_package() {
1045        let files = [make_file("a.proto", "", vec![msg("Foo")], vec![])];
1046        let config = CodeGenConfig::default();
1047        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1048        assert_eq!(ctx.rust_type_relative(".Foo", "", 0), Some("Foo".into()));
1049    }
1050
1051    #[test]
1052    fn test_relative_unknown_returns_none() {
1053        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
1054        let config = CodeGenConfig::default();
1055        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1056        assert_eq!(ctx.rust_type_relative(".pkg.Unknown", "pkg", 0), None);
1057    }
1058
1059    #[test]
1060    fn test_relative_dotted_package() {
1061        let files = [make_file("a.proto", "my.pkg", vec![msg("Foo")], vec![])];
1062        let config = CodeGenConfig::default();
1063        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1064        assert_eq!(
1065            ctx.rust_type_relative(".my.pkg.Foo", "my.pkg", 0),
1066            Some("Foo".into())
1067        );
1068    }
1069
1070    #[test]
1071    fn test_relative_cross_dotted_packages() {
1072        let files = [
1073            make_file(
1074                "timestamp.proto",
1075                "google.protobuf",
1076                vec![msg("Timestamp")],
1077                vec![],
1078            ),
1079            make_file(
1080                "test.proto",
1081                "protobuf_test_messages.proto3",
1082                vec![msg("TestAllTypesProto3")],
1083                vec![],
1084            ),
1085        ];
1086        let config = CodeGenConfig::default();
1087        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1088
1089        // Cross-package: relative via super:: (no common prefix, up 2 levels).
1090        assert_eq!(
1091            ctx.rust_type_relative(
1092                ".google.protobuf.Timestamp",
1093                "protobuf_test_messages.proto3",
1094                0,
1095            ),
1096            Some("super::super::google::protobuf::Timestamp".into())
1097        );
1098    }
1099
1100    #[test]
1101    fn test_relative_nested_type_from_same_package() {
1102        // Referencing Outer.Inner from the same package.
1103        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1104        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1105        let config = CodeGenConfig::default();
1106        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1107
1108        // Same package: strips the package prefix, keeps module path.
1109        assert_eq!(
1110            ctx.rust_type_relative(".pkg.Outer.Inner", "pkg", 0),
1111            Some("outer::Inner".into())
1112        );
1113    }
1114
1115    #[test]
1116    fn test_relative_shared_prefix_not_confused() {
1117        let files = [
1118            make_file("ab.proto", "a.b", vec![msg("Msg1")], vec![]),
1119            make_file("abc.proto", "a.bc", vec![msg("Msg2")], vec![]),
1120        ];
1121        let config = CodeGenConfig::default();
1122        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1123
1124        // `a.b.Msg1` from `a.bc` context: common prefix "a", up 1, into "b".
1125        assert_eq!(
1126            ctx.rust_type_relative(".a.b.Msg1", "a.bc", 0),
1127            Some("super::b::Msg1".into())
1128        );
1129        // `a.bc.Msg2` from `a.b` context: common prefix "a", up 1, into "bc".
1130        assert_eq!(
1131            ctx.rust_type_relative(".a.bc.Msg2", "a.b", 0),
1132            Some("super::bc::Msg2".into())
1133        );
1134    }
1135
1136    // ── Nesting depth tests ────────────────────────────────────────────
1137
1138    #[test]
1139    fn test_relative_cross_package_nesting_1() {
1140        // Simulates a nested message (inside a `pub mod`) referencing a type
1141        // from a sibling package. E.g., account.business.admin.v1 nested msg
1142        // referencing account.business.v1.Business.Status.
1143        let outer = msg_with_nested_and_enums("Business", vec![], vec![enum_desc("Status")]);
1144        let files = [
1145            make_file("admin.proto", "a.b.admin.v1", vec![msg("Svc")], vec![]),
1146            make_file("biz.proto", "a.b.v1", vec![outer], vec![]),
1147        ];
1148        let config = CodeGenConfig::default();
1149        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1150
1151        // nesting=0 (top-level struct in admin.v1): up 2 (v1→admin), into v1
1152        assert_eq!(
1153            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 0),
1154            Some("super::super::v1::business::Status".into())
1155        );
1156        // nesting=1 (inside a nested message module): one extra super::
1157        assert_eq!(
1158            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 1),
1159            Some("super::super::super::v1::business::Status".into())
1160        );
1161    }
1162
1163    #[test]
1164    fn test_relative_same_package_nesting_1() {
1165        // Nested message referencing a sibling type in the same package.
1166        let files = [make_file(
1167            "test.proto",
1168            "pkg",
1169            vec![msg("Foo"), msg("Bar")],
1170            vec![],
1171        )];
1172        let config = CodeGenConfig::default();
1173        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1174
1175        // nesting=0: same package, just the name
1176        assert_eq!(
1177            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
1178            Some("Foo".into())
1179        );
1180        // nesting=1: inside a message module, needs one super::
1181        assert_eq!(
1182            ctx.rust_type_relative(".pkg.Foo", "pkg", 1),
1183            Some("super::Foo".into())
1184        );
1185        // nesting=2: doubly nested
1186        assert_eq!(
1187            ctx.rust_type_relative(".pkg.Foo", "pkg", 2),
1188            Some("super::super::Foo".into())
1189        );
1190    }
1191
1192    // ── Extern path tests ─────────────────────────────────────────────
1193
1194    #[test]
1195    fn test_resolve_extern_prefix_exact_match() {
1196        let result = resolve_extern_prefix(
1197            "my.common",
1198            &[(".my.common".into(), "::common_protos".into())],
1199        );
1200        assert_eq!(result, Some("::common_protos".into()));
1201    }
1202
1203    #[test]
1204    fn test_resolve_extern_prefix_sub_package() {
1205        let result = resolve_extern_prefix(
1206            "my.common.sub",
1207            &[(".my.common".into(), "::common_protos".into())],
1208        );
1209        assert_eq!(result, Some("::common_protos::sub".into()));
1210    }
1211
1212    #[test]
1213    fn test_resolve_extern_prefix_no_match() {
1214        let result = resolve_extern_prefix(
1215            "other.pkg",
1216            &[(".my.common".into(), "::common_protos".into())],
1217        );
1218        assert_eq!(result, None);
1219    }
1220
1221    #[test]
1222    fn test_resolve_extern_prefix_partial_name_no_match() {
1223        // ".my.common" should not match ".my.commonext"
1224        let result = resolve_extern_prefix(
1225            "my.commonext",
1226            &[(".my.common".into(), "::common_protos".into())],
1227        );
1228        assert_eq!(result, None);
1229    }
1230
1231    #[test]
1232    fn test_resolve_extern_prefix_longest_match_wins() {
1233        // When multiple prefixes match, the longest one should win.
1234        let result = resolve_extern_prefix(
1235            "my.common.sub",
1236            &[
1237                (".my".into(), "::crate_a".into()),
1238                (".my.common".into(), "::crate_b".into()),
1239            ],
1240        );
1241        assert_eq!(result, Some("::crate_b::sub".into()));
1242    }
1243
1244    #[test]
1245    fn test_resolve_extern_prefix_catchall() {
1246        let result = resolve_extern_prefix("greet.v1", &[(".".into(), "crate::proto".into())]);
1247        assert_eq!(result, Some("crate::proto::greet::v1".into()));
1248    }
1249
1250    #[test]
1251    fn test_resolve_extern_prefix_catchall_empty_pkg() {
1252        // Empty package with `.` catch-all hits the exact-match branch
1253        // (dotted == "." == proto_prefix) and returns the root as-is.
1254        let result = resolve_extern_prefix("", &[(".".into(), "crate::proto".into())]);
1255        assert_eq!(result, Some("crate::proto".into()));
1256    }
1257
1258    #[test]
1259    fn test_resolve_extern_prefix_catchall_longest_wins() {
1260        // `.` catch-all is the shortest possible prefix; any more-specific
1261        // mapping (including the auto-injected WKT mapping) takes priority.
1262        let result = resolve_extern_prefix(
1263            "google.protobuf",
1264            &[
1265                (".".into(), "crate::proto".into()),
1266                (
1267                    ".google.protobuf".into(),
1268                    "::buffa_types::google::protobuf".into(),
1269                ),
1270            ],
1271        );
1272        assert_eq!(result, Some("::buffa_types::google::protobuf".into()));
1273    }
1274
1275    #[test]
1276    fn test_resolve_extern_prefix_catchall_keyword_package() {
1277        // Keyword segments stay unescaped at the string level; escaping to
1278        // `r#type` happens later in `idents::rust_path_to_tokens`.
1279        let result = resolve_extern_prefix("google.type", &[(".".into(), "crate::proto".into())]);
1280        assert_eq!(result, Some("crate::proto::google::type".into()));
1281    }
1282
1283    // ── rust_type_relative_split — extern branch ────────────────────────
1284
1285    #[test]
1286    fn test_split_extern_top_level() {
1287        let outer = msg_with_nested("Value", vec![msg("Inner")]);
1288        let files = [make_file(
1289            "struct.proto",
1290            "google.protobuf",
1291            vec![outer],
1292            vec![],
1293        )];
1294        let config = CodeGenConfig::default();
1295        let extern_paths = vec![(
1296            ".google.protobuf".into(),
1297            "::buffa_types::google::protobuf".into(),
1298        )];
1299        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
1300
1301        let split = ctx
1302            .rust_type_relative_split(".google.protobuf.Value", "my.pkg", 3)
1303            .expect("type resolves");
1304        assert!(split.is_extern);
1305        // Extern path is absolute → nesting irrelevant.
1306        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
1307        assert_eq!(split.within_package, "Value");
1308    }
1309
1310    #[test]
1311    fn test_split_extern_nested_type() {
1312        // Nested `.google.protobuf.Value.Inner` →
1313        // extern path `::buffa_types::google::protobuf::value::Inner`.
1314        // Segment-count slice: 2 within-package segments → cut after the
1315        // extern module prefix.
1316        let outer = msg_with_nested("Value", vec![msg("Inner")]);
1317        let files = [make_file(
1318            "struct.proto",
1319            "google.protobuf",
1320            vec![outer],
1321            vec![],
1322        )];
1323        let config = CodeGenConfig::default();
1324        let extern_paths = vec![(
1325            ".google.protobuf".into(),
1326            "::buffa_types::google::protobuf".into(),
1327        )];
1328        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
1329
1330        let split = ctx
1331            .rust_type_relative_split(".google.protobuf.Value.Inner", "my.pkg", 0)
1332            .expect("nested type resolves");
1333        assert!(split.is_extern);
1334        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
1335        assert_eq!(split.within_package, "value::Inner");
1336    }
1337
1338    #[test]
1339    fn test_extern_path_top_level_message() {
1340        let files = [make_file(
1341            "common.proto",
1342            "my.common",
1343            vec![msg("SharedMsg")],
1344            vec![],
1345        )];
1346        let config = CodeGenConfig {
1347            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1348            ..Default::default()
1349        };
1350        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1351        assert_eq!(
1352            ctx.rust_type(".my.common.SharedMsg"),
1353            Some("::common_protos::SharedMsg")
1354        );
1355    }
1356
1357    #[test]
1358    fn test_extern_path_nested_message() {
1359        let files = [make_file(
1360            "common.proto",
1361            "my.common",
1362            vec![msg_with_nested("Outer", vec![msg("Inner")])],
1363            vec![],
1364        )];
1365        let config = CodeGenConfig {
1366            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1367            ..Default::default()
1368        };
1369        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1370        assert_eq!(
1371            ctx.rust_type(".my.common.Outer"),
1372            Some("::common_protos::Outer")
1373        );
1374        assert_eq!(
1375            ctx.rust_type(".my.common.Outer.Inner"),
1376            Some("::common_protos::outer::Inner")
1377        );
1378    }
1379
1380    #[test]
1381    fn test_extern_path_enum() {
1382        let files = [make_file(
1383            "common.proto",
1384            "my.common",
1385            vec![],
1386            vec![enum_desc("Status")],
1387        )];
1388        let config = CodeGenConfig {
1389            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1390            ..Default::default()
1391        };
1392        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1393        assert_eq!(
1394            ctx.rust_type(".my.common.Status"),
1395            Some("::common_protos::Status")
1396        );
1397    }
1398
1399    #[test]
1400    fn test_extern_path_does_not_affect_other_packages() {
1401        let files = [
1402            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
1403            make_file(
1404                "service.proto",
1405                "my.service",
1406                vec![msg("MyService")],
1407                vec![],
1408            ),
1409        ];
1410        let config = CodeGenConfig {
1411            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1412            ..Default::default()
1413        };
1414        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1415        // Extern type uses absolute path.
1416        assert_eq!(
1417            ctx.rust_type(".my.common.SharedMsg"),
1418            Some("::common_protos::SharedMsg")
1419        );
1420        // Non-extern type uses normal package-derived path.
1421        assert_eq!(
1422            ctx.rust_type(".my.service.MyService"),
1423            Some("my::service::MyService")
1424        );
1425    }
1426
1427    #[test]
1428    fn test_extern_path_relative_returns_absolute() {
1429        // When an extern type is referenced from another package,
1430        // rust_type_relative should return the full absolute path.
1431        let files = [
1432            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
1433            make_file(
1434                "service.proto",
1435                "my.service",
1436                vec![msg("MyService")],
1437                vec![],
1438            ),
1439        ];
1440        let config = CodeGenConfig {
1441            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1442            ..Default::default()
1443        };
1444        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1445        // Cross-package reference to extern type: absolute path.
1446        assert_eq!(
1447            ctx.rust_type_relative(".my.common.SharedMsg", "my.service", 0),
1448            Some("::common_protos::SharedMsg".into())
1449        );
1450    }
1451
1452    // ── is_enum_closed tests ──────────────────────────────────────────────
1453
1454    #[test]
1455    fn test_is_enum_closed_proto3_default_open() {
1456        let files = [make_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1457        let config = CodeGenConfig::default();
1458        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1459        // proto3 default (make_file has no syntax = proto2/implicit)
1460        // actually make_file doesn't set syntax, so it's proto2 default...
1461        // proto2 default is CLOSED.
1462        assert_eq!(ctx.is_enum_closed(".p.E"), Some(true));
1463    }
1464
1465    #[test]
1466    fn test_is_enum_closed_editions_default_open() {
1467        let files = [editions_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1468        let config = CodeGenConfig::default();
1469        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1470        // Edition 2023 default is OPEN.
1471        assert_eq!(ctx.is_enum_closed(".p.E"), Some(false));
1472    }
1473
1474    #[test]
1475    fn test_is_enum_closed_per_enum_override() {
1476        // This is THE bug: enum with `option features.enum_type = CLOSED`
1477        // in an otherwise-open editions file must be detected as closed.
1478        let files = [editions_file(
1479            "a.proto",
1480            "p",
1481            vec![],
1482            vec![enum_desc("Open"), enum_with_closed_feature("Closed")],
1483        )];
1484        let config = CodeGenConfig::default();
1485        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1486        assert_eq!(ctx.is_enum_closed(".p.Open"), Some(false));
1487        assert_eq!(ctx.is_enum_closed(".p.Closed"), Some(true));
1488    }
1489
1490    #[test]
1491    fn test_is_enum_closed_nested_per_enum_override() {
1492        // Feature resolution through file → message → enum.
1493        let files = [editions_file(
1494            "a.proto",
1495            "p",
1496            vec![msg_with_nested_and_enums(
1497                "M",
1498                vec![],
1499                vec![enum_with_closed_feature("Inner")],
1500            )],
1501            vec![],
1502        )];
1503        let config = CodeGenConfig::default();
1504        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1505        assert_eq!(ctx.is_enum_closed(".p.M.Inner"), Some(true));
1506    }
1507
1508    #[test]
1509    fn test_is_enum_closed_unknown_enum_returns_none() {
1510        let files = [editions_file("a.proto", "p", vec![], vec![])];
1511        let config = CodeGenConfig::default();
1512        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1513        // extern_path or missing enum → None (caller falls back).
1514        assert_eq!(ctx.is_enum_closed(".other.Unknown"), None);
1515    }
1516
1517    #[test]
1518    fn test_for_generate_auto_injects_wkt_mapping() {
1519        // for_generate() must produce the same type_map as generate() uses
1520        // internally — including the auto-injected WKT extern_path.
1521        let ts_msg = DescriptorProto {
1522            name: Some("Timestamp".into()),
1523            ..Default::default()
1524        };
1525        let files = [FileDescriptorProto {
1526            name: Some("google/protobuf/timestamp.proto".into()),
1527            package: Some("google.protobuf".into()),
1528            syntax: Some("proto3".into()),
1529            message_type: vec![ts_msg],
1530            ..Default::default()
1531        }];
1532        let config = CodeGenConfig::default();
1533        // Not generating the WKT file itself → auto-mapping should kick in.
1534        let ctx = CodeGenContext::for_generate(&files, &["other.proto".into()], &config);
1535        assert_eq!(
1536            ctx.rust_type(".google.protobuf.Timestamp"),
1537            Some("::buffa_types::google::protobuf::Timestamp"),
1538            "WKT auto-mapping must be applied via for_generate"
1539        );
1540    }
1541
1542    #[test]
1543    fn test_for_generate_suppresses_wkt_when_generating_wkt() {
1544        // When files_to_generate includes a google.protobuf file (building
1545        // buffa-types itself), the WKT auto-mapping must NOT be applied.
1546        let ts_msg = DescriptorProto {
1547            name: Some("Timestamp".into()),
1548            ..Default::default()
1549        };
1550        let files = [FileDescriptorProto {
1551            name: Some("google/protobuf/timestamp.proto".into()),
1552            package: Some("google.protobuf".into()),
1553            syntax: Some("proto3".into()),
1554            message_type: vec![ts_msg],
1555            ..Default::default()
1556        }];
1557        let config = CodeGenConfig::default();
1558        let ctx = CodeGenContext::for_generate(
1559            &files,
1560            &["google/protobuf/timestamp.proto".into()],
1561            &config,
1562        );
1563        // No extern mapping → local-package path.
1564        assert_eq!(
1565            ctx.rust_type(".google.protobuf.Timestamp"),
1566            Some("google::protobuf::Timestamp")
1567        );
1568    }
1569
1570    // ── matching_attributes tests ──────────────────────────────────────
1571
1572    #[test]
1573    fn test_matching_attributes_catchall() {
1574        // "." matches every type.
1575        let attrs = vec![(".".into(), "#[derive(Foo)]".into())];
1576        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1577        assert!(result.to_string().contains("derive"));
1578    }
1579
1580    #[test]
1581    fn test_matching_attributes_exact_match() {
1582        let attrs = vec![(".my.pkg.MyMessage".into(), "#[derive(Bar)]".into())];
1583        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1584        assert!(result.to_string().contains("derive"));
1585    }
1586
1587    #[test]
1588    fn test_matching_attributes_package_prefix() {
1589        let attrs = vec![(".my.pkg".into(), "#[derive(Baz)]".into())];
1590        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1591        assert!(result.to_string().contains("derive"));
1592    }
1593
1594    #[test]
1595    fn test_matching_attributes_no_partial_segment_match() {
1596        // ".my.pk" must not match ".my.pkg" (partial segment).
1597        let attrs = vec![(".my.pk".into(), "#[derive(Bad)]".into())];
1598        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1599        assert!(result.is_empty());
1600    }
1601
1602    #[test]
1603    fn test_matching_attributes_no_match() {
1604        let attrs = vec![(".other.pkg".into(), "#[derive(Nope)]".into())];
1605        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1606        assert!(result.is_empty());
1607    }
1608
1609    #[test]
1610    fn test_matching_attributes_multiple_accumulate() {
1611        // All matching entries are emitted, not just the first.
1612        let attrs = vec![
1613            (".".into(), "#[derive(A)]".into()),
1614            (".my.pkg".into(), "#[derive(B)]".into()),
1615        ];
1616        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1617        let s = result.to_string();
1618        assert!(s.contains("A") && s.contains("B"));
1619    }
1620
1621    #[test]
1622    fn test_matching_attributes_invalid_attr_errors() {
1623        // Unparseable attributes surface as a hard error so the user sees
1624        // the problem at build time rather than a silently-dropped attribute.
1625        let attrs = vec![(".".into(), "not valid {{{{".into())];
1626        let err = CodeGenContext::matching_attributes(&attrs, "my.pkg.Msg").unwrap_err();
1627        assert!(matches!(
1628            err,
1629            crate::CodeGenError::InvalidCustomAttribute { .. }
1630        ));
1631    }
1632
1633    #[test]
1634    fn test_matches_proto_prefix_catchall() {
1635        assert!(matches_proto_prefix(".", ".anything.here"));
1636        assert!(matches_proto_prefix(".", "."));
1637    }
1638
1639    #[test]
1640    fn test_matches_proto_prefix_segment_boundary() {
1641        // Segment-aware: ".my.pk" must not match ".my.pkg".
1642        assert!(!matches_proto_prefix(".my.pk", ".my.pkg.Msg"));
1643        // But full-segment prefix match does.
1644        assert!(matches_proto_prefix(".my.pkg", ".my.pkg.Msg"));
1645    }
1646}