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/// Shared context for a code generation run.
11///
12/// Holds the full set of file descriptors and a mapping from fully-qualified
13/// protobuf type names to their Rust type paths. This is needed because a
14/// field in one `.proto` file may reference a message defined in another.
15pub struct CodeGenContext<'a> {
16    /// All file descriptors (both requested and dependencies).
17    pub files: &'a [FileDescriptorProto],
18    /// Code generation configuration.
19    pub config: &'a CodeGenConfig,
20    /// Map from fully-qualified protobuf name (e.g., ".my.package.MyMessage")
21    /// to Rust type path (e.g., "my::package::MyMessage").
22    ///
23    /// Nested types use module-qualified paths:
24    /// ".pkg.Outer.Inner" → "pkg::outer::Inner" (not "pkg::OuterInner").
25    pub type_map: HashMap<String, String>,
26    /// Map from fully-qualified protobuf name to its proto package.
27    ///
28    /// Used by `rust_type_relative` to compute `super::`-based relative
29    /// paths for cross-package references within the same compilation.
30    package_of: HashMap<String, String>,
31    /// Map from fully-qualified enum name to its resolved `enum_type` feature.
32    ///
33    /// The `enum_type` feature determines whether an enum is OPEN or CLOSED.
34    /// It's resolved from the ENUM's own file → message → enum feature chain,
35    /// NOT from the referencing field's chain. protoc does not propagate
36    /// enum-level `enum_type` into field options (verified 2026-03), so
37    /// callers must look this up via `is_enum_closed`.
38    enum_closedness: HashMap<String, bool>,
39}
40
41impl<'a> CodeGenContext<'a> {
42    /// Build a context from file descriptors, populating the type map.
43    ///
44    /// `effective_extern_paths` includes both user-provided mappings and any
45    /// auto-injected defaults (e.g., the WKT mapping). These are computed by
46    /// `crate::effective_extern_paths` before calling this constructor.
47    pub fn new(
48        files: &'a [FileDescriptorProto],
49        config: &'a CodeGenConfig,
50        effective_extern_paths: &[(String, String)],
51    ) -> Self {
52        let mut type_map = HashMap::new();
53        let mut package_of = HashMap::new();
54        let mut enum_closedness = HashMap::new();
55
56        for file in files {
57            let package = file.package.as_deref().unwrap_or("");
58            let file_features = features::for_file(file);
59            let proto_prefix = if package.is_empty() {
60                String::from(".")
61            } else {
62                format!(".{}.", package)
63            };
64
65            // Check if this file's package matches an extern_path.
66            // If so, types are registered with the extern Rust path prefix.
67            let rust_module =
68                if let Some(rust_root) = resolve_extern_prefix(package, effective_extern_paths) {
69                    rust_root
70                } else {
71                    package.replace('.', "::")
72                };
73
74            // Register top-level messages
75            for msg in &file.message_type {
76                if let Some(name) = &msg.name {
77                    let fqn = format!("{}{}", proto_prefix, name);
78                    let rust_path = if rust_module.is_empty() {
79                        name.clone()
80                    } else {
81                        format!("{}::{}", rust_module, name)
82                    };
83                    type_map.insert(fqn.clone(), rust_path);
84                    package_of.insert(fqn.clone(), package.to_string());
85
86                    // Register nested messages using module-qualified paths.
87                    let snake = to_snake_case(name);
88                    let parent_mod = if rust_module.is_empty() {
89                        snake
90                    } else {
91                        format!("{}::{}", rust_module, snake)
92                    };
93                    register_nested_types(
94                        &mut type_map,
95                        &mut package_of,
96                        package,
97                        &fqn,
98                        &parent_mod,
99                        msg,
100                    );
101                    register_nested_enum_closedness(
102                        &mut enum_closedness,
103                        &fqn,
104                        &file_features,
105                        msg,
106                    );
107                }
108            }
109
110            // Register top-level enums
111            for enum_type in &file.enum_type {
112                if let Some(name) = &enum_type.name {
113                    let fqn = format!("{}{}", proto_prefix, name);
114                    let rust_path = if rust_module.is_empty() {
115                        name.clone()
116                    } else {
117                        format!("{}::{}", rust_module, name)
118                    };
119                    type_map.insert(fqn.clone(), rust_path);
120                    package_of.insert(fqn.clone(), package.to_string());
121                    register_enum_closedness(&mut enum_closedness, &fqn, &file_features, enum_type);
122                }
123            }
124        }
125
126        Self {
127            files,
128            config,
129            type_map,
130            package_of,
131            enum_closedness,
132        }
133    }
134
135    /// Build a context matching what [`generate()`](crate::generate) uses
136    /// internally.
137    ///
138    /// Computes effective extern paths (user-provided + auto-injected WKT
139    /// mapping to `buffa-types`) and builds the type map from them.
140    ///
141    /// Convenience for downstream generators (e.g. `connectrpc-codegen`)
142    /// that emit code alongside buffa's message types and need identical
143    /// type-path resolution. Using this instead of [`new()`](Self::new) +
144    /// manual extern-path computation ensures zero drift with buffa's own
145    /// generation.
146    pub fn for_generate(
147        files: &'a [FileDescriptorProto],
148        files_to_generate: &[String],
149        config: &'a CodeGenConfig,
150    ) -> Self {
151        let paths = crate::effective_extern_paths(files, files_to_generate, config);
152        Self::new(files, config, &paths)
153    }
154
155    /// Look up the Rust type path for a fully-qualified protobuf type name.
156    pub fn rust_type(&self, proto_fqn: &str) -> Option<&str> {
157        self.type_map.get(proto_fqn).map(|s| s.as_str())
158    }
159
160    /// Look up whether an enum (by fully-qualified proto name) is closed.
161    ///
162    /// Returns `None` if the enum is not in this compilation set (e.g., an
163    /// extern_path type), in which case callers should fall back to the
164    /// referencing field's feature chain (correct for proto2/proto3 where
165    /// `enum_type` is file-level anyway).
166    pub fn is_enum_closed(&self, proto_fqn: &str) -> Option<bool> {
167        self.enum_closedness.get(proto_fqn).copied()
168    }
169
170    /// Look up the Rust type path relative to the current code generation
171    /// scope.
172    ///
173    /// `current_package` is the proto package (e.g., `"google.protobuf"`).
174    /// `nesting` is the number of message module levels the generated code
175    /// sits inside (0 for struct fields and impls at the package level,
176    /// 1 for oneof enums inside a message module, etc.).
177    ///
178    /// - **Same package**: strips the package prefix and prepends `super::`
179    ///   for each nesting level.
180    /// - **Cross package (local)**: navigates via `super::` to the common
181    ///   ancestor, then descends into the target package. This works
182    ///   regardless of where the module tree is placed in the user's crate.
183    /// - **Cross package (extern)**: returns the absolute extern path as-is.
184    pub fn rust_type_relative(
185        &self,
186        proto_fqn: &str,
187        current_package: &str,
188        nesting: usize,
189    ) -> Option<String> {
190        let full_path = self.type_map.get(proto_fqn)?;
191
192        // Extern types use absolute paths (starting with `::` or `crate::`)
193        // and need no relative resolution — they work from any module position.
194        if full_path.starts_with("::") || full_path.starts_with("crate::") {
195            return Some(full_path.clone());
196        }
197
198        let target_package = self
199            .package_of
200            .get(proto_fqn)
201            .map(|s| s.as_str())
202            .unwrap_or("");
203
204        // Extract the type's path within its package (everything after the
205        // package module prefix).
206        let target_rust_module = target_package.replace('.', "::");
207        let type_suffix = if target_rust_module.is_empty() {
208            full_path.as_str()
209        } else {
210            full_path
211                .strip_prefix(&format!("{}::", target_rust_module))
212                .unwrap_or(full_path)
213        };
214
215        if current_package == target_package {
216            // Same package — just the type suffix, with super:: for nesting.
217            if nesting == 0 {
218                return Some(type_suffix.to_string());
219            }
220            let supers = (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::");
221            return Some(format!("{}::{}", supers, type_suffix));
222        }
223
224        // Cross-package local type: compute a super::-based relative path.
225        let current_parts: Vec<&str> = if current_package.is_empty() {
226            vec![]
227        } else {
228            current_package.split('.').collect()
229        };
230        let target_parts: Vec<&str> = if target_package.is_empty() {
231            vec![]
232        } else {
233            target_package.split('.').collect()
234        };
235
236        // Find the length of the common package prefix.
237        let common_len = current_parts
238            .iter()
239            .zip(&target_parts)
240            .take_while(|(a, b)| a == b)
241            .count();
242
243        // Navigate up: one super:: per remaining current package segment,
244        // plus one per nesting level (message module depth).
245        let up_count = (current_parts.len() - common_len) + nesting;
246
247        // Navigate down: target package segments beyond the common prefix.
248        let down_parts = &target_parts[common_len..];
249
250        let mut segments: Vec<&str> = vec!["super"; up_count];
251        segments.extend_from_slice(down_parts);
252
253        // Append the type's within-package path.
254        let mut result = segments.join("::");
255        if !result.is_empty() {
256            result.push_str("::");
257        }
258        result.push_str(type_suffix);
259
260        Some(result)
261    }
262
263    /// Check whether a bytes field at the given proto path should use
264    /// `bytes::Bytes` instead of `Vec<u8>`.
265    ///
266    /// `field_fqn` is the fully-qualified proto field path, e.g.,
267    /// `".my.pkg.MyMessage.data"`. Matches against `config.bytes_fields`
268    /// entries, which are prefix-matched (so `"."` matches all fields).
269    pub fn use_bytes_type(&self, field_fqn: &str) -> bool {
270        self.config
271            .bytes_fields
272            .iter()
273            .any(|prefix| field_fqn.starts_with(prefix.as_str()))
274    }
275}
276
277/// Check if a proto package matches any extern_path prefix.
278///
279/// Returns the Rust module path root if matched, including any remaining
280/// package segments converted to `snake_case` modules. For example,
281/// extern_path `(".my", "::my_crate")` with package `"my.sub.pkg"` returns
282/// `"::my_crate::sub::pkg"`.
283fn resolve_extern_prefix(package: &str, extern_paths: &[(String, String)]) -> Option<String> {
284    let dotted = format!(".{}", package);
285
286    // Try longest prefix first so that more specific mappings take priority
287    // over broader ones (e.g., ".my.common" before ".my").
288    let mut best: Option<(&str, &str, usize)> = None;
289
290    for (proto_prefix, rust_prefix) in extern_paths {
291        if dotted == *proto_prefix {
292            // Exact match is always the best.
293            return Some(rust_prefix.clone());
294        }
295        if let Some(rest) = dotted.strip_prefix(proto_prefix.as_str()) {
296            // `"."` is the catch-all root; stripping it leaves no leading dot.
297            if proto_prefix == "." || rest.starts_with('.') {
298                let prefix_len = proto_prefix.len();
299                if best.is_none_or(|(_, _, best_len)| prefix_len > best_len) {
300                    best = Some((proto_prefix, rust_prefix, prefix_len));
301                }
302            }
303        }
304    }
305
306    let (proto_prefix, rust_prefix, _) = best?;
307    let rest = dotted.strip_prefix(proto_prefix)?;
308    let rest = rest.strip_prefix('.').unwrap_or(rest);
309    let suffix = rest
310        .split('.')
311        .map(to_snake_case)
312        .collect::<Vec<_>>()
313        .join("::");
314    Some(format!("{}::{}", rust_prefix, suffix))
315}
316
317/// Recursively register nested messages and enums with module-qualified paths.
318///
319/// Each nested message `Parent.Child` maps to `parent_mod::Child` in Rust,
320/// where `parent_mod` is the snake_case module path of the enclosing message.
321fn register_nested_types(
322    type_map: &mut HashMap<String, String>,
323    package_of: &mut HashMap<String, String>,
324    package: &str,
325    parent_fqn: &str,
326    parent_mod: &str,
327    msg: &crate::generated::descriptor::DescriptorProto,
328) {
329    for nested in &msg.nested_type {
330        if let Some(name) = &nested.name {
331            let fqn = format!("{}.{}", parent_fqn, name);
332            let rust_path = format!("{}::{}", parent_mod, name);
333            type_map.insert(fqn.clone(), rust_path);
334            package_of.insert(fqn.clone(), package.to_string());
335
336            // Recurse: nested-of-nested goes in a deeper module.
337            let child_mod = format!("{}::{}", parent_mod, to_snake_case(name));
338            register_nested_types(type_map, package_of, package, &fqn, &child_mod, nested);
339        }
340    }
341
342    for enum_type in &msg.enum_type {
343        if let Some(name) = &enum_type.name {
344            let fqn = format!("{}.{}", parent_fqn, name);
345            let rust_path = format!("{}::{}", parent_mod, name);
346            type_map.insert(fqn.clone(), rust_path);
347            package_of.insert(fqn, package.to_string());
348        }
349    }
350}
351
352/// Resolve and record whether an enum is closed, given its parent's features.
353fn register_enum_closedness(
354    map: &mut HashMap<String, bool>,
355    fqn: &str,
356    parent_features: &ResolvedFeatures,
357    enum_desc: &EnumDescriptorProto,
358) {
359    let resolved = features::resolve_child(parent_features, features::enum_features(enum_desc));
360    let closed = resolved.enum_type == features::EnumType::Closed;
361    map.insert(fqn.to_string(), closed);
362}
363
364/// Walk nested messages and register all enum closedness, resolving features
365/// through the message hierarchy (file → msg → nested_msg → enum).
366fn register_nested_enum_closedness(
367    map: &mut HashMap<String, bool>,
368    parent_fqn: &str,
369    parent_features: &ResolvedFeatures,
370    msg: &DescriptorProto,
371) {
372    let msg_features = features::resolve_child(parent_features, features::message_features(msg));
373    for enum_type in &msg.enum_type {
374        if let Some(name) = &enum_type.name {
375            let fqn = format!("{}.{}", parent_fqn, name);
376            register_enum_closedness(map, &fqn, &msg_features, enum_type);
377        }
378    }
379    for nested in &msg.nested_type {
380        if let Some(name) = &nested.name {
381            let fqn = format!("{}.{}", parent_fqn, name);
382            register_nested_enum_closedness(map, &fqn, &msg_features, nested);
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
391
392    fn make_file(
393        name: &str,
394        package: &str,
395        messages: Vec<DescriptorProto>,
396        enums: Vec<EnumDescriptorProto>,
397    ) -> FileDescriptorProto {
398        FileDescriptorProto {
399            name: Some(name.to_string()),
400            package: if package.is_empty() {
401                None
402            } else {
403                Some(package.to_string())
404            },
405            message_type: messages,
406            enum_type: enums,
407            ..Default::default()
408        }
409    }
410
411    fn msg(name: &str) -> DescriptorProto {
412        DescriptorProto {
413            name: Some(name.to_string()),
414            ..Default::default()
415        }
416    }
417
418    fn msg_with_nested(name: &str, nested: Vec<DescriptorProto>) -> DescriptorProto {
419        DescriptorProto {
420            name: Some(name.to_string()),
421            nested_type: nested,
422            ..Default::default()
423        }
424    }
425
426    fn msg_with_nested_and_enums(
427        name: &str,
428        nested: Vec<DescriptorProto>,
429        enums: Vec<EnumDescriptorProto>,
430    ) -> DescriptorProto {
431        DescriptorProto {
432            name: Some(name.to_string()),
433            nested_type: nested,
434            enum_type: enums,
435            ..Default::default()
436        }
437    }
438
439    fn enum_desc(name: &str) -> EnumDescriptorProto {
440        EnumDescriptorProto {
441            name: Some(name.to_string()),
442            ..Default::default()
443        }
444    }
445
446    fn enum_with_closed_feature(name: &str) -> EnumDescriptorProto {
447        use crate::generated::descriptor::{feature_set, EnumOptions, FeatureSet};
448        EnumDescriptorProto {
449            name: Some(name.to_string()),
450            options: buffa::MessageField::some(EnumOptions {
451                features: buffa::MessageField::some(FeatureSet {
452                    enum_type: Some(feature_set::EnumType::CLOSED),
453                    ..Default::default()
454                }),
455                ..Default::default()
456            }),
457            ..Default::default()
458        }
459    }
460
461    fn editions_file(
462        name: &str,
463        package: &str,
464        messages: Vec<DescriptorProto>,
465        enums: Vec<EnumDescriptorProto>,
466    ) -> FileDescriptorProto {
467        use crate::generated::descriptor::Edition;
468        FileDescriptorProto {
469            name: Some(name.to_string()),
470            package: Some(package.to_string()),
471            syntax: Some("editions".to_string()),
472            edition: Some(Edition::EDITION_2023),
473            message_type: messages,
474            enum_type: enums,
475            ..Default::default()
476        }
477    }
478
479    // ── Type registration tests ──────────────────────────────────────────
480
481    #[test]
482    fn test_message_with_package() {
483        let files = [make_file(
484            "test.proto",
485            "my.package",
486            vec![msg("Foo")],
487            vec![],
488        )];
489        let config = CodeGenConfig::default();
490        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
491        assert_eq!(ctx.rust_type(".my.package.Foo"), Some("my::package::Foo"));
492    }
493
494    #[test]
495    fn test_message_no_package() {
496        let files = [make_file("test.proto", "", vec![msg("Bar")], vec![])];
497        let config = CodeGenConfig::default();
498        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
499        assert_eq!(ctx.rust_type(".Bar"), Some("Bar"));
500    }
501
502    #[test]
503    fn test_nested_message_uses_module_path() {
504        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
505        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
506        let config = CodeGenConfig::default();
507        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
508        assert_eq!(ctx.rust_type(".pkg.Outer"), Some("pkg::Outer"));
509        // Nested types use module-qualified paths.
510        assert_eq!(ctx.rust_type(".pkg.Outer.Inner"), Some("pkg::outer::Inner"));
511    }
512
513    #[test]
514    fn test_nested_message_no_package() {
515        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
516        let files = [make_file("test.proto", "", vec![outer], vec![])];
517        let config = CodeGenConfig::default();
518        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
519        assert_eq!(ctx.rust_type(".Outer"), Some("Outer"));
520        assert_eq!(ctx.rust_type(".Outer.Inner"), Some("outer::Inner"));
521    }
522
523    #[test]
524    fn test_deeply_nested_message() {
525        let deep = msg_with_nested("A", vec![msg_with_nested("B", vec![msg("C")])]);
526        let files = [make_file("test.proto", "pkg", vec![deep], vec![])];
527        let config = CodeGenConfig::default();
528        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
529        assert_eq!(ctx.rust_type(".pkg.A"), Some("pkg::A"));
530        assert_eq!(ctx.rust_type(".pkg.A.B"), Some("pkg::a::B"));
531        assert_eq!(ctx.rust_type(".pkg.A.B.C"), Some("pkg::a::b::C"));
532    }
533
534    #[test]
535    fn test_nested_enum_uses_module_path() {
536        let outer = msg_with_nested_and_enums("Outer", vec![], vec![enum_desc("Status")]);
537        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
538        let config = CodeGenConfig::default();
539        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
540        assert_eq!(
541            ctx.rust_type(".pkg.Outer.Status"),
542            Some("pkg::outer::Status")
543        );
544    }
545
546    #[test]
547    fn test_top_level_enum() {
548        let files = [make_file(
549            "test.proto",
550            "pkg",
551            vec![],
552            vec![enum_desc("Status")],
553        )];
554        let config = CodeGenConfig::default();
555        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
556        assert_eq!(ctx.rust_type(".pkg.Status"), Some("pkg::Status"));
557    }
558
559    #[test]
560    fn test_same_named_nested_types_in_different_parents_are_distinct() {
561        let outer1 = msg_with_nested("Outer1", vec![msg("Inner")]);
562        let outer2 = msg_with_nested("Outer2", vec![msg("Inner")]);
563        let files = [make_file("a.proto", "pkg", vec![outer1, outer2], vec![])];
564        let config = CodeGenConfig::default();
565        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
566        // Different parent modules make them distinct.
567        assert_eq!(
568            ctx.rust_type(".pkg.Outer1.Inner"),
569            Some("pkg::outer1::Inner")
570        );
571        assert_eq!(
572            ctx.rust_type(".pkg.Outer2.Inner"),
573            Some("pkg::outer2::Inner")
574        );
575        assert_ne!(
576            ctx.rust_type(".pkg.Outer1.Inner"),
577            ctx.rust_type(".pkg.Outer2.Inner")
578        );
579    }
580
581    #[test]
582    fn test_multiple_files() {
583        let files = [
584            make_file("a.proto", "ns.a", vec![msg("MsgA")], vec![]),
585            make_file("b.proto", "ns.b", vec![msg("MsgB")], vec![]),
586        ];
587        let config = CodeGenConfig::default();
588        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
589        assert_eq!(ctx.rust_type(".ns.a.MsgA"), Some("ns::a::MsgA"));
590        assert_eq!(ctx.rust_type(".ns.b.MsgB"), Some("ns::b::MsgB"));
591    }
592
593    #[test]
594    fn test_keyword_package_segment_in_type_map() {
595        // Proto package `google.type` — the type map stores plain string paths.
596        // Keyword escaping happens at the token level, not in the type map.
597        let files = [make_file(
598            "latlng.proto",
599            "google.type",
600            vec![msg("LatLng")],
601            vec![],
602        )];
603        let config = CodeGenConfig::default();
604        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
605        assert_eq!(
606            ctx.rust_type(".google.type.LatLng"),
607            Some("google::type::LatLng")
608        );
609    }
610
611    #[test]
612    fn test_keyword_package_relative_same_package() {
613        let files = [make_file(
614            "latlng.proto",
615            "google.type",
616            vec![msg("LatLng"), msg("Expr")],
617            vec![],
618        )];
619        let config = CodeGenConfig::default();
620        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
621        // Same-package reference: just the type name (no module prefix).
622        assert_eq!(
623            ctx.rust_type_relative(".google.type.LatLng", "google.type", 0),
624            Some("LatLng".into())
625        );
626    }
627
628    #[test]
629    fn test_keyword_package_cross_package() {
630        let files = [
631            make_file("latlng.proto", "google.type", vec![msg("LatLng")], vec![]),
632            make_file("svc.proto", "google.cloud", vec![msg("Service")], vec![]),
633        ];
634        let config = CodeGenConfig::default();
635        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
636        // Cross-package: relative path via super:: (keyword escaping at token level).
637        // From google.cloud, go up one (past "cloud"), then into "type".
638        assert_eq!(
639            ctx.rust_type_relative(".google.type.LatLng", "google.cloud", 0),
640            Some("super::type::LatLng".into())
641        );
642    }
643
644    #[test]
645    fn test_keyword_nested_message_module() {
646        // Message named "Type" → module "type" in type map.
647        let outer = msg_with_nested("Type", vec![msg("Inner")]);
648        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
649        let config = CodeGenConfig::default();
650        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
651        assert_eq!(ctx.rust_type(".pkg.Type"), Some("pkg::Type"));
652        assert_eq!(ctx.rust_type(".pkg.Type.Inner"), Some("pkg::type::Inner"));
653    }
654
655    #[test]
656    fn test_unknown_type_returns_none() {
657        let files = [make_file("test.proto", "pkg", vec![msg("Foo")], vec![])];
658        let config = CodeGenConfig::default();
659        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
660        assert_eq!(ctx.rust_type(".pkg.Unknown"), None);
661    }
662
663    // ── Relative type resolution tests ───────────────────────────────────
664
665    #[test]
666    fn test_relative_same_package_top_level() {
667        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
668        let config = CodeGenConfig::default();
669        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
670        // From top-level in same package: just the type name.
671        assert_eq!(
672            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
673            Some("Foo".into())
674        );
675    }
676
677    #[test]
678    fn test_relative_cross_package() {
679        let files = [
680            make_file("a.proto", "pkg_a", vec![msg("Foo")], vec![]),
681            make_file("b.proto", "pkg_b", vec![msg("Bar")], vec![]),
682        ];
683        let config = CodeGenConfig::default();
684        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
685        // Cross-package: relative via super:: (up one from pkg_b, into pkg_a).
686        assert_eq!(
687            ctx.rust_type_relative(".pkg_a.Foo", "pkg_b", 0),
688            Some("super::pkg_a::Foo".into())
689        );
690    }
691
692    #[test]
693    fn test_relative_no_package() {
694        let files = [make_file("a.proto", "", vec![msg("Foo")], vec![])];
695        let config = CodeGenConfig::default();
696        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
697        assert_eq!(ctx.rust_type_relative(".Foo", "", 0), Some("Foo".into()));
698    }
699
700    #[test]
701    fn test_relative_unknown_returns_none() {
702        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
703        let config = CodeGenConfig::default();
704        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
705        assert_eq!(ctx.rust_type_relative(".pkg.Unknown", "pkg", 0), None);
706    }
707
708    #[test]
709    fn test_relative_dotted_package() {
710        let files = [make_file("a.proto", "my.pkg", vec![msg("Foo")], vec![])];
711        let config = CodeGenConfig::default();
712        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
713        assert_eq!(
714            ctx.rust_type_relative(".my.pkg.Foo", "my.pkg", 0),
715            Some("Foo".into())
716        );
717    }
718
719    #[test]
720    fn test_relative_cross_dotted_packages() {
721        let files = [
722            make_file(
723                "timestamp.proto",
724                "google.protobuf",
725                vec![msg("Timestamp")],
726                vec![],
727            ),
728            make_file(
729                "test.proto",
730                "protobuf_test_messages.proto3",
731                vec![msg("TestAllTypesProto3")],
732                vec![],
733            ),
734        ];
735        let config = CodeGenConfig::default();
736        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
737
738        // Cross-package: relative via super:: (no common prefix, up 2 levels).
739        assert_eq!(
740            ctx.rust_type_relative(
741                ".google.protobuf.Timestamp",
742                "protobuf_test_messages.proto3",
743                0,
744            ),
745            Some("super::super::google::protobuf::Timestamp".into())
746        );
747    }
748
749    #[test]
750    fn test_relative_nested_type_from_same_package() {
751        // Referencing Outer.Inner from the same package.
752        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
753        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
754        let config = CodeGenConfig::default();
755        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
756
757        // Same package: strips the package prefix, keeps module path.
758        assert_eq!(
759            ctx.rust_type_relative(".pkg.Outer.Inner", "pkg", 0),
760            Some("outer::Inner".into())
761        );
762    }
763
764    #[test]
765    fn test_relative_shared_prefix_not_confused() {
766        let files = [
767            make_file("ab.proto", "a.b", vec![msg("Msg1")], vec![]),
768            make_file("abc.proto", "a.bc", vec![msg("Msg2")], vec![]),
769        ];
770        let config = CodeGenConfig::default();
771        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
772
773        // `a.b.Msg1` from `a.bc` context: common prefix "a", up 1, into "b".
774        assert_eq!(
775            ctx.rust_type_relative(".a.b.Msg1", "a.bc", 0),
776            Some("super::b::Msg1".into())
777        );
778        // `a.bc.Msg2` from `a.b` context: common prefix "a", up 1, into "bc".
779        assert_eq!(
780            ctx.rust_type_relative(".a.bc.Msg2", "a.b", 0),
781            Some("super::bc::Msg2".into())
782        );
783    }
784
785    // ── Extern path tests ─────────────────────────────────────────────
786
787    #[test]
788    fn test_resolve_extern_prefix_exact_match() {
789        let result = resolve_extern_prefix(
790            "my.common",
791            &[(".my.common".into(), "::common_protos".into())],
792        );
793        assert_eq!(result, Some("::common_protos".into()));
794    }
795
796    #[test]
797    fn test_resolve_extern_prefix_sub_package() {
798        let result = resolve_extern_prefix(
799            "my.common.sub",
800            &[(".my.common".into(), "::common_protos".into())],
801        );
802        assert_eq!(result, Some("::common_protos::sub".into()));
803    }
804
805    #[test]
806    fn test_resolve_extern_prefix_no_match() {
807        let result = resolve_extern_prefix(
808            "other.pkg",
809            &[(".my.common".into(), "::common_protos".into())],
810        );
811        assert_eq!(result, None);
812    }
813
814    #[test]
815    fn test_resolve_extern_prefix_partial_name_no_match() {
816        // ".my.common" should not match ".my.commonext"
817        let result = resolve_extern_prefix(
818            "my.commonext",
819            &[(".my.common".into(), "::common_protos".into())],
820        );
821        assert_eq!(result, None);
822    }
823
824    #[test]
825    fn test_resolve_extern_prefix_longest_match_wins() {
826        // When multiple prefixes match, the longest one should win.
827        let result = resolve_extern_prefix(
828            "my.common.sub",
829            &[
830                (".my".into(), "::crate_a".into()),
831                (".my.common".into(), "::crate_b".into()),
832            ],
833        );
834        assert_eq!(result, Some("::crate_b::sub".into()));
835    }
836
837    #[test]
838    fn test_resolve_extern_prefix_catchall() {
839        let result = resolve_extern_prefix("greet.v1", &[(".".into(), "crate::proto".into())]);
840        assert_eq!(result, Some("crate::proto::greet::v1".into()));
841    }
842
843    #[test]
844    fn test_resolve_extern_prefix_catchall_empty_pkg() {
845        // Empty package with `.` catch-all hits the exact-match branch
846        // (dotted == "." == proto_prefix) and returns the root as-is.
847        let result = resolve_extern_prefix("", &[(".".into(), "crate::proto".into())]);
848        assert_eq!(result, Some("crate::proto".into()));
849    }
850
851    #[test]
852    fn test_resolve_extern_prefix_catchall_longest_wins() {
853        // `.` catch-all is the shortest possible prefix; any more-specific
854        // mapping (including the auto-injected WKT mapping) takes priority.
855        let result = resolve_extern_prefix(
856            "google.protobuf",
857            &[
858                (".".into(), "crate::proto".into()),
859                (
860                    ".google.protobuf".into(),
861                    "::buffa_types::google::protobuf".into(),
862                ),
863            ],
864        );
865        assert_eq!(result, Some("::buffa_types::google::protobuf".into()));
866    }
867
868    #[test]
869    fn test_resolve_extern_prefix_catchall_keyword_package() {
870        // Keyword segments stay unescaped at the string level; escaping to
871        // `r#type` happens later in `idents::rust_path_to_tokens`.
872        let result = resolve_extern_prefix("google.type", &[(".".into(), "crate::proto".into())]);
873        assert_eq!(result, Some("crate::proto::google::type".into()));
874    }
875
876    #[test]
877    fn test_extern_path_top_level_message() {
878        let files = [make_file(
879            "common.proto",
880            "my.common",
881            vec![msg("SharedMsg")],
882            vec![],
883        )];
884        let config = CodeGenConfig {
885            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
886            ..Default::default()
887        };
888        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
889        assert_eq!(
890            ctx.rust_type(".my.common.SharedMsg"),
891            Some("::common_protos::SharedMsg")
892        );
893    }
894
895    #[test]
896    fn test_extern_path_nested_message() {
897        let files = [make_file(
898            "common.proto",
899            "my.common",
900            vec![msg_with_nested("Outer", vec![msg("Inner")])],
901            vec![],
902        )];
903        let config = CodeGenConfig {
904            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
905            ..Default::default()
906        };
907        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
908        assert_eq!(
909            ctx.rust_type(".my.common.Outer"),
910            Some("::common_protos::Outer")
911        );
912        assert_eq!(
913            ctx.rust_type(".my.common.Outer.Inner"),
914            Some("::common_protos::outer::Inner")
915        );
916    }
917
918    #[test]
919    fn test_extern_path_enum() {
920        let files = [make_file(
921            "common.proto",
922            "my.common",
923            vec![],
924            vec![enum_desc("Status")],
925        )];
926        let config = CodeGenConfig {
927            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
928            ..Default::default()
929        };
930        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
931        assert_eq!(
932            ctx.rust_type(".my.common.Status"),
933            Some("::common_protos::Status")
934        );
935    }
936
937    #[test]
938    fn test_extern_path_does_not_affect_other_packages() {
939        let files = [
940            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
941            make_file(
942                "service.proto",
943                "my.service",
944                vec![msg("MyService")],
945                vec![],
946            ),
947        ];
948        let config = CodeGenConfig {
949            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
950            ..Default::default()
951        };
952        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
953        // Extern type uses absolute path.
954        assert_eq!(
955            ctx.rust_type(".my.common.SharedMsg"),
956            Some("::common_protos::SharedMsg")
957        );
958        // Non-extern type uses normal package-derived path.
959        assert_eq!(
960            ctx.rust_type(".my.service.MyService"),
961            Some("my::service::MyService")
962        );
963    }
964
965    #[test]
966    fn test_extern_path_relative_returns_absolute() {
967        // When an extern type is referenced from another package,
968        // rust_type_relative should return the full absolute path.
969        let files = [
970            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
971            make_file(
972                "service.proto",
973                "my.service",
974                vec![msg("MyService")],
975                vec![],
976            ),
977        ];
978        let config = CodeGenConfig {
979            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
980            ..Default::default()
981        };
982        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
983        // Cross-package reference to extern type: absolute path.
984        assert_eq!(
985            ctx.rust_type_relative(".my.common.SharedMsg", "my.service", 0),
986            Some("::common_protos::SharedMsg".into())
987        );
988    }
989
990    // ── is_enum_closed tests ──────────────────────────────────────────────
991
992    #[test]
993    fn test_is_enum_closed_proto3_default_open() {
994        let files = [make_file("a.proto", "p", vec![], vec![enum_desc("E")])];
995        let config = CodeGenConfig::default();
996        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
997        // proto3 default (make_file has no syntax = proto2/implicit)
998        // actually make_file doesn't set syntax, so it's proto2 default...
999        // proto2 default is CLOSED.
1000        assert_eq!(ctx.is_enum_closed(".p.E"), Some(true));
1001    }
1002
1003    #[test]
1004    fn test_is_enum_closed_editions_default_open() {
1005        let files = [editions_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1006        let config = CodeGenConfig::default();
1007        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1008        // Edition 2023 default is OPEN.
1009        assert_eq!(ctx.is_enum_closed(".p.E"), Some(false));
1010    }
1011
1012    #[test]
1013    fn test_is_enum_closed_per_enum_override() {
1014        // This is THE bug: enum with `option features.enum_type = CLOSED`
1015        // in an otherwise-open editions file must be detected as closed.
1016        let files = [editions_file(
1017            "a.proto",
1018            "p",
1019            vec![],
1020            vec![enum_desc("Open"), enum_with_closed_feature("Closed")],
1021        )];
1022        let config = CodeGenConfig::default();
1023        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1024        assert_eq!(ctx.is_enum_closed(".p.Open"), Some(false));
1025        assert_eq!(ctx.is_enum_closed(".p.Closed"), Some(true));
1026    }
1027
1028    #[test]
1029    fn test_is_enum_closed_nested_per_enum_override() {
1030        // Feature resolution through file → message → enum.
1031        let files = [editions_file(
1032            "a.proto",
1033            "p",
1034            vec![msg_with_nested_and_enums(
1035                "M",
1036                vec![],
1037                vec![enum_with_closed_feature("Inner")],
1038            )],
1039            vec![],
1040        )];
1041        let config = CodeGenConfig::default();
1042        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1043        assert_eq!(ctx.is_enum_closed(".p.M.Inner"), Some(true));
1044    }
1045
1046    #[test]
1047    fn test_is_enum_closed_unknown_enum_returns_none() {
1048        let files = [editions_file("a.proto", "p", vec![], vec![])];
1049        let config = CodeGenConfig::default();
1050        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1051        // extern_path or missing enum → None (caller falls back).
1052        assert_eq!(ctx.is_enum_closed(".other.Unknown"), None);
1053    }
1054
1055    #[test]
1056    fn test_for_generate_auto_injects_wkt_mapping() {
1057        // for_generate() must produce the same type_map as generate() uses
1058        // internally — including the auto-injected WKT extern_path.
1059        let ts_msg = DescriptorProto {
1060            name: Some("Timestamp".into()),
1061            ..Default::default()
1062        };
1063        let files = [FileDescriptorProto {
1064            name: Some("google/protobuf/timestamp.proto".into()),
1065            package: Some("google.protobuf".into()),
1066            syntax: Some("proto3".into()),
1067            message_type: vec![ts_msg],
1068            ..Default::default()
1069        }];
1070        let config = CodeGenConfig::default();
1071        // Not generating the WKT file itself → auto-mapping should kick in.
1072        let ctx = CodeGenContext::for_generate(&files, &["other.proto".into()], &config);
1073        assert_eq!(
1074            ctx.rust_type(".google.protobuf.Timestamp"),
1075            Some("::buffa_types::google::protobuf::Timestamp"),
1076            "WKT auto-mapping must be applied via for_generate"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_for_generate_suppresses_wkt_when_generating_wkt() {
1082        // When files_to_generate includes a google.protobuf file (building
1083        // buffa-types itself), the WKT auto-mapping must NOT be applied.
1084        let ts_msg = DescriptorProto {
1085            name: Some("Timestamp".into()),
1086            ..Default::default()
1087        };
1088        let files = [FileDescriptorProto {
1089            name: Some("google/protobuf/timestamp.proto".into()),
1090            package: Some("google.protobuf".into()),
1091            syntax: Some("proto3".into()),
1092            message_type: vec![ts_msg],
1093            ..Default::default()
1094        }];
1095        let config = CodeGenConfig::default();
1096        let ctx = CodeGenContext::for_generate(
1097            &files,
1098            &["google/protobuf/timestamp.proto".into()],
1099            &config,
1100        );
1101        // No extern mapping → local-package path.
1102        assert_eq!(
1103            ctx.rust_type(".google.protobuf.Timestamp"),
1104            Some("google::protobuf::Timestamp")
1105        );
1106    }
1107}