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            if rest.starts_with('.') {
297                let prefix_len = proto_prefix.len();
298                if best.is_none_or(|(_, _, best_len)| prefix_len > best_len) {
299                    best = Some((proto_prefix, rust_prefix, prefix_len));
300                }
301            }
302        }
303    }
304
305    let (proto_prefix, rust_prefix, _) = best?;
306    let rest = dotted.strip_prefix(proto_prefix)?.strip_prefix('.')?;
307    let suffix = rest
308        .split('.')
309        .map(to_snake_case)
310        .collect::<Vec<_>>()
311        .join("::");
312    Some(format!("{}::{}", rust_prefix, suffix))
313}
314
315/// Recursively register nested messages and enums with module-qualified paths.
316///
317/// Each nested message `Parent.Child` maps to `parent_mod::Child` in Rust,
318/// where `parent_mod` is the snake_case module path of the enclosing message.
319fn register_nested_types(
320    type_map: &mut HashMap<String, String>,
321    package_of: &mut HashMap<String, String>,
322    package: &str,
323    parent_fqn: &str,
324    parent_mod: &str,
325    msg: &crate::generated::descriptor::DescriptorProto,
326) {
327    for nested in &msg.nested_type {
328        if let Some(name) = &nested.name {
329            let fqn = format!("{}.{}", parent_fqn, name);
330            let rust_path = format!("{}::{}", parent_mod, name);
331            type_map.insert(fqn.clone(), rust_path);
332            package_of.insert(fqn.clone(), package.to_string());
333
334            // Recurse: nested-of-nested goes in a deeper module.
335            let child_mod = format!("{}::{}", parent_mod, to_snake_case(name));
336            register_nested_types(type_map, package_of, package, &fqn, &child_mod, nested);
337        }
338    }
339
340    for enum_type in &msg.enum_type {
341        if let Some(name) = &enum_type.name {
342            let fqn = format!("{}.{}", parent_fqn, name);
343            let rust_path = format!("{}::{}", parent_mod, name);
344            type_map.insert(fqn.clone(), rust_path);
345            package_of.insert(fqn, package.to_string());
346        }
347    }
348}
349
350/// Resolve and record whether an enum is closed, given its parent's features.
351fn register_enum_closedness(
352    map: &mut HashMap<String, bool>,
353    fqn: &str,
354    parent_features: &ResolvedFeatures,
355    enum_desc: &EnumDescriptorProto,
356) {
357    let resolved = features::resolve_child(parent_features, features::enum_features(enum_desc));
358    let closed = resolved.enum_type == features::EnumType::Closed;
359    map.insert(fqn.to_string(), closed);
360}
361
362/// Walk nested messages and register all enum closedness, resolving features
363/// through the message hierarchy (file → msg → nested_msg → enum).
364fn register_nested_enum_closedness(
365    map: &mut HashMap<String, bool>,
366    parent_fqn: &str,
367    parent_features: &ResolvedFeatures,
368    msg: &DescriptorProto,
369) {
370    let msg_features = features::resolve_child(parent_features, features::message_features(msg));
371    for enum_type in &msg.enum_type {
372        if let Some(name) = &enum_type.name {
373            let fqn = format!("{}.{}", parent_fqn, name);
374            register_enum_closedness(map, &fqn, &msg_features, enum_type);
375        }
376    }
377    for nested in &msg.nested_type {
378        if let Some(name) = &nested.name {
379            let fqn = format!("{}.{}", parent_fqn, name);
380            register_nested_enum_closedness(map, &fqn, &msg_features, nested);
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
389
390    fn make_file(
391        name: &str,
392        package: &str,
393        messages: Vec<DescriptorProto>,
394        enums: Vec<EnumDescriptorProto>,
395    ) -> FileDescriptorProto {
396        FileDescriptorProto {
397            name: Some(name.to_string()),
398            package: if package.is_empty() {
399                None
400            } else {
401                Some(package.to_string())
402            },
403            message_type: messages,
404            enum_type: enums,
405            ..Default::default()
406        }
407    }
408
409    fn msg(name: &str) -> DescriptorProto {
410        DescriptorProto {
411            name: Some(name.to_string()),
412            ..Default::default()
413        }
414    }
415
416    fn msg_with_nested(name: &str, nested: Vec<DescriptorProto>) -> DescriptorProto {
417        DescriptorProto {
418            name: Some(name.to_string()),
419            nested_type: nested,
420            ..Default::default()
421        }
422    }
423
424    fn msg_with_nested_and_enums(
425        name: &str,
426        nested: Vec<DescriptorProto>,
427        enums: Vec<EnumDescriptorProto>,
428    ) -> DescriptorProto {
429        DescriptorProto {
430            name: Some(name.to_string()),
431            nested_type: nested,
432            enum_type: enums,
433            ..Default::default()
434        }
435    }
436
437    fn enum_desc(name: &str) -> EnumDescriptorProto {
438        EnumDescriptorProto {
439            name: Some(name.to_string()),
440            ..Default::default()
441        }
442    }
443
444    fn enum_with_closed_feature(name: &str) -> EnumDescriptorProto {
445        use crate::generated::descriptor::{feature_set, EnumOptions, FeatureSet};
446        EnumDescriptorProto {
447            name: Some(name.to_string()),
448            options: buffa::MessageField::some(EnumOptions {
449                features: buffa::MessageField::some(FeatureSet {
450                    enum_type: Some(feature_set::EnumType::CLOSED),
451                    ..Default::default()
452                }),
453                ..Default::default()
454            }),
455            ..Default::default()
456        }
457    }
458
459    fn editions_file(
460        name: &str,
461        package: &str,
462        messages: Vec<DescriptorProto>,
463        enums: Vec<EnumDescriptorProto>,
464    ) -> FileDescriptorProto {
465        use crate::generated::descriptor::Edition;
466        FileDescriptorProto {
467            name: Some(name.to_string()),
468            package: Some(package.to_string()),
469            syntax: Some("editions".to_string()),
470            edition: Some(Edition::EDITION_2023),
471            message_type: messages,
472            enum_type: enums,
473            ..Default::default()
474        }
475    }
476
477    // ── Type registration tests ──────────────────────────────────────────
478
479    #[test]
480    fn test_message_with_package() {
481        let files = [make_file(
482            "test.proto",
483            "my.package",
484            vec![msg("Foo")],
485            vec![],
486        )];
487        let config = CodeGenConfig::default();
488        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
489        assert_eq!(ctx.rust_type(".my.package.Foo"), Some("my::package::Foo"));
490    }
491
492    #[test]
493    fn test_message_no_package() {
494        let files = [make_file("test.proto", "", vec![msg("Bar")], vec![])];
495        let config = CodeGenConfig::default();
496        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
497        assert_eq!(ctx.rust_type(".Bar"), Some("Bar"));
498    }
499
500    #[test]
501    fn test_nested_message_uses_module_path() {
502        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
503        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
504        let config = CodeGenConfig::default();
505        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
506        assert_eq!(ctx.rust_type(".pkg.Outer"), Some("pkg::Outer"));
507        // Nested types use module-qualified paths.
508        assert_eq!(ctx.rust_type(".pkg.Outer.Inner"), Some("pkg::outer::Inner"));
509    }
510
511    #[test]
512    fn test_nested_message_no_package() {
513        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
514        let files = [make_file("test.proto", "", vec![outer], vec![])];
515        let config = CodeGenConfig::default();
516        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
517        assert_eq!(ctx.rust_type(".Outer"), Some("Outer"));
518        assert_eq!(ctx.rust_type(".Outer.Inner"), Some("outer::Inner"));
519    }
520
521    #[test]
522    fn test_deeply_nested_message() {
523        let deep = msg_with_nested("A", vec![msg_with_nested("B", vec![msg("C")])]);
524        let files = [make_file("test.proto", "pkg", vec![deep], vec![])];
525        let config = CodeGenConfig::default();
526        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
527        assert_eq!(ctx.rust_type(".pkg.A"), Some("pkg::A"));
528        assert_eq!(ctx.rust_type(".pkg.A.B"), Some("pkg::a::B"));
529        assert_eq!(ctx.rust_type(".pkg.A.B.C"), Some("pkg::a::b::C"));
530    }
531
532    #[test]
533    fn test_nested_enum_uses_module_path() {
534        let outer = msg_with_nested_and_enums("Outer", vec![], vec![enum_desc("Status")]);
535        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
536        let config = CodeGenConfig::default();
537        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
538        assert_eq!(
539            ctx.rust_type(".pkg.Outer.Status"),
540            Some("pkg::outer::Status")
541        );
542    }
543
544    #[test]
545    fn test_top_level_enum() {
546        let files = [make_file(
547            "test.proto",
548            "pkg",
549            vec![],
550            vec![enum_desc("Status")],
551        )];
552        let config = CodeGenConfig::default();
553        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
554        assert_eq!(ctx.rust_type(".pkg.Status"), Some("pkg::Status"));
555    }
556
557    #[test]
558    fn test_same_named_nested_types_in_different_parents_are_distinct() {
559        let outer1 = msg_with_nested("Outer1", vec![msg("Inner")]);
560        let outer2 = msg_with_nested("Outer2", vec![msg("Inner")]);
561        let files = [make_file("a.proto", "pkg", vec![outer1, outer2], vec![])];
562        let config = CodeGenConfig::default();
563        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
564        // Different parent modules make them distinct.
565        assert_eq!(
566            ctx.rust_type(".pkg.Outer1.Inner"),
567            Some("pkg::outer1::Inner")
568        );
569        assert_eq!(
570            ctx.rust_type(".pkg.Outer2.Inner"),
571            Some("pkg::outer2::Inner")
572        );
573        assert_ne!(
574            ctx.rust_type(".pkg.Outer1.Inner"),
575            ctx.rust_type(".pkg.Outer2.Inner")
576        );
577    }
578
579    #[test]
580    fn test_multiple_files() {
581        let files = [
582            make_file("a.proto", "ns.a", vec![msg("MsgA")], vec![]),
583            make_file("b.proto", "ns.b", vec![msg("MsgB")], vec![]),
584        ];
585        let config = CodeGenConfig::default();
586        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
587        assert_eq!(ctx.rust_type(".ns.a.MsgA"), Some("ns::a::MsgA"));
588        assert_eq!(ctx.rust_type(".ns.b.MsgB"), Some("ns::b::MsgB"));
589    }
590
591    #[test]
592    fn test_keyword_package_segment_in_type_map() {
593        // Proto package `google.type` — the type map stores plain string paths.
594        // Keyword escaping happens at the token level, not in the type map.
595        let files = [make_file(
596            "latlng.proto",
597            "google.type",
598            vec![msg("LatLng")],
599            vec![],
600        )];
601        let config = CodeGenConfig::default();
602        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
603        assert_eq!(
604            ctx.rust_type(".google.type.LatLng"),
605            Some("google::type::LatLng")
606        );
607    }
608
609    #[test]
610    fn test_keyword_package_relative_same_package() {
611        let files = [make_file(
612            "latlng.proto",
613            "google.type",
614            vec![msg("LatLng"), msg("Expr")],
615            vec![],
616        )];
617        let config = CodeGenConfig::default();
618        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
619        // Same-package reference: just the type name (no module prefix).
620        assert_eq!(
621            ctx.rust_type_relative(".google.type.LatLng", "google.type", 0),
622            Some("LatLng".into())
623        );
624    }
625
626    #[test]
627    fn test_keyword_package_cross_package() {
628        let files = [
629            make_file("latlng.proto", "google.type", vec![msg("LatLng")], vec![]),
630            make_file("svc.proto", "google.cloud", vec![msg("Service")], vec![]),
631        ];
632        let config = CodeGenConfig::default();
633        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
634        // Cross-package: relative path via super:: (keyword escaping at token level).
635        // From google.cloud, go up one (past "cloud"), then into "type".
636        assert_eq!(
637            ctx.rust_type_relative(".google.type.LatLng", "google.cloud", 0),
638            Some("super::type::LatLng".into())
639        );
640    }
641
642    #[test]
643    fn test_keyword_nested_message_module() {
644        // Message named "Type" → module "type" in type map.
645        let outer = msg_with_nested("Type", vec![msg("Inner")]);
646        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
647        let config = CodeGenConfig::default();
648        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
649        assert_eq!(ctx.rust_type(".pkg.Type"), Some("pkg::Type"));
650        assert_eq!(ctx.rust_type(".pkg.Type.Inner"), Some("pkg::type::Inner"));
651    }
652
653    #[test]
654    fn test_unknown_type_returns_none() {
655        let files = [make_file("test.proto", "pkg", vec![msg("Foo")], vec![])];
656        let config = CodeGenConfig::default();
657        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
658        assert_eq!(ctx.rust_type(".pkg.Unknown"), None);
659    }
660
661    // ── Relative type resolution tests ───────────────────────────────────
662
663    #[test]
664    fn test_relative_same_package_top_level() {
665        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
666        let config = CodeGenConfig::default();
667        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
668        // From top-level in same package: just the type name.
669        assert_eq!(
670            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
671            Some("Foo".into())
672        );
673    }
674
675    #[test]
676    fn test_relative_cross_package() {
677        let files = [
678            make_file("a.proto", "pkg_a", vec![msg("Foo")], vec![]),
679            make_file("b.proto", "pkg_b", vec![msg("Bar")], vec![]),
680        ];
681        let config = CodeGenConfig::default();
682        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
683        // Cross-package: relative via super:: (up one from pkg_b, into pkg_a).
684        assert_eq!(
685            ctx.rust_type_relative(".pkg_a.Foo", "pkg_b", 0),
686            Some("super::pkg_a::Foo".into())
687        );
688    }
689
690    #[test]
691    fn test_relative_no_package() {
692        let files = [make_file("a.proto", "", vec![msg("Foo")], vec![])];
693        let config = CodeGenConfig::default();
694        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
695        assert_eq!(ctx.rust_type_relative(".Foo", "", 0), Some("Foo".into()));
696    }
697
698    #[test]
699    fn test_relative_unknown_returns_none() {
700        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
701        let config = CodeGenConfig::default();
702        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
703        assert_eq!(ctx.rust_type_relative(".pkg.Unknown", "pkg", 0), None);
704    }
705
706    #[test]
707    fn test_relative_dotted_package() {
708        let files = [make_file("a.proto", "my.pkg", vec![msg("Foo")], vec![])];
709        let config = CodeGenConfig::default();
710        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
711        assert_eq!(
712            ctx.rust_type_relative(".my.pkg.Foo", "my.pkg", 0),
713            Some("Foo".into())
714        );
715    }
716
717    #[test]
718    fn test_relative_cross_dotted_packages() {
719        let files = [
720            make_file(
721                "timestamp.proto",
722                "google.protobuf",
723                vec![msg("Timestamp")],
724                vec![],
725            ),
726            make_file(
727                "test.proto",
728                "protobuf_test_messages.proto3",
729                vec![msg("TestAllTypesProto3")],
730                vec![],
731            ),
732        ];
733        let config = CodeGenConfig::default();
734        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
735
736        // Cross-package: relative via super:: (no common prefix, up 2 levels).
737        assert_eq!(
738            ctx.rust_type_relative(
739                ".google.protobuf.Timestamp",
740                "protobuf_test_messages.proto3",
741                0,
742            ),
743            Some("super::super::google::protobuf::Timestamp".into())
744        );
745    }
746
747    #[test]
748    fn test_relative_nested_type_from_same_package() {
749        // Referencing Outer.Inner from the same package.
750        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
751        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
752        let config = CodeGenConfig::default();
753        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
754
755        // Same package: strips the package prefix, keeps module path.
756        assert_eq!(
757            ctx.rust_type_relative(".pkg.Outer.Inner", "pkg", 0),
758            Some("outer::Inner".into())
759        );
760    }
761
762    #[test]
763    fn test_relative_shared_prefix_not_confused() {
764        let files = [
765            make_file("ab.proto", "a.b", vec![msg("Msg1")], vec![]),
766            make_file("abc.proto", "a.bc", vec![msg("Msg2")], vec![]),
767        ];
768        let config = CodeGenConfig::default();
769        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
770
771        // `a.b.Msg1` from `a.bc` context: common prefix "a", up 1, into "b".
772        assert_eq!(
773            ctx.rust_type_relative(".a.b.Msg1", "a.bc", 0),
774            Some("super::b::Msg1".into())
775        );
776        // `a.bc.Msg2` from `a.b` context: common prefix "a", up 1, into "bc".
777        assert_eq!(
778            ctx.rust_type_relative(".a.bc.Msg2", "a.b", 0),
779            Some("super::bc::Msg2".into())
780        );
781    }
782
783    // ── Extern path tests ─────────────────────────────────────────────
784
785    #[test]
786    fn test_resolve_extern_prefix_exact_match() {
787        let result = resolve_extern_prefix(
788            "my.common",
789            &[(".my.common".into(), "::common_protos".into())],
790        );
791        assert_eq!(result, Some("::common_protos".into()));
792    }
793
794    #[test]
795    fn test_resolve_extern_prefix_sub_package() {
796        let result = resolve_extern_prefix(
797            "my.common.sub",
798            &[(".my.common".into(), "::common_protos".into())],
799        );
800        assert_eq!(result, Some("::common_protos::sub".into()));
801    }
802
803    #[test]
804    fn test_resolve_extern_prefix_no_match() {
805        let result = resolve_extern_prefix(
806            "other.pkg",
807            &[(".my.common".into(), "::common_protos".into())],
808        );
809        assert_eq!(result, None);
810    }
811
812    #[test]
813    fn test_resolve_extern_prefix_partial_name_no_match() {
814        // ".my.common" should not match ".my.commonext"
815        let result = resolve_extern_prefix(
816            "my.commonext",
817            &[(".my.common".into(), "::common_protos".into())],
818        );
819        assert_eq!(result, None);
820    }
821
822    #[test]
823    fn test_resolve_extern_prefix_longest_match_wins() {
824        // When multiple prefixes match, the longest one should win.
825        let result = resolve_extern_prefix(
826            "my.common.sub",
827            &[
828                (".my".into(), "::crate_a".into()),
829                (".my.common".into(), "::crate_b".into()),
830            ],
831        );
832        assert_eq!(result, Some("::crate_b::sub".into()));
833    }
834
835    #[test]
836    fn test_extern_path_top_level_message() {
837        let files = [make_file(
838            "common.proto",
839            "my.common",
840            vec![msg("SharedMsg")],
841            vec![],
842        )];
843        let config = CodeGenConfig {
844            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
845            ..Default::default()
846        };
847        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
848        assert_eq!(
849            ctx.rust_type(".my.common.SharedMsg"),
850            Some("::common_protos::SharedMsg")
851        );
852    }
853
854    #[test]
855    fn test_extern_path_nested_message() {
856        let files = [make_file(
857            "common.proto",
858            "my.common",
859            vec![msg_with_nested("Outer", vec![msg("Inner")])],
860            vec![],
861        )];
862        let config = CodeGenConfig {
863            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
864            ..Default::default()
865        };
866        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
867        assert_eq!(
868            ctx.rust_type(".my.common.Outer"),
869            Some("::common_protos::Outer")
870        );
871        assert_eq!(
872            ctx.rust_type(".my.common.Outer.Inner"),
873            Some("::common_protos::outer::Inner")
874        );
875    }
876
877    #[test]
878    fn test_extern_path_enum() {
879        let files = [make_file(
880            "common.proto",
881            "my.common",
882            vec![],
883            vec![enum_desc("Status")],
884        )];
885        let config = CodeGenConfig {
886            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
887            ..Default::default()
888        };
889        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
890        assert_eq!(
891            ctx.rust_type(".my.common.Status"),
892            Some("::common_protos::Status")
893        );
894    }
895
896    #[test]
897    fn test_extern_path_does_not_affect_other_packages() {
898        let files = [
899            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
900            make_file(
901                "service.proto",
902                "my.service",
903                vec![msg("MyService")],
904                vec![],
905            ),
906        ];
907        let config = CodeGenConfig {
908            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
909            ..Default::default()
910        };
911        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
912        // Extern type uses absolute path.
913        assert_eq!(
914            ctx.rust_type(".my.common.SharedMsg"),
915            Some("::common_protos::SharedMsg")
916        );
917        // Non-extern type uses normal package-derived path.
918        assert_eq!(
919            ctx.rust_type(".my.service.MyService"),
920            Some("my::service::MyService")
921        );
922    }
923
924    #[test]
925    fn test_extern_path_relative_returns_absolute() {
926        // When an extern type is referenced from another package,
927        // rust_type_relative should return the full absolute path.
928        let files = [
929            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
930            make_file(
931                "service.proto",
932                "my.service",
933                vec![msg("MyService")],
934                vec![],
935            ),
936        ];
937        let config = CodeGenConfig {
938            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
939            ..Default::default()
940        };
941        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
942        // Cross-package reference to extern type: absolute path.
943        assert_eq!(
944            ctx.rust_type_relative(".my.common.SharedMsg", "my.service", 0),
945            Some("::common_protos::SharedMsg".into())
946        );
947    }
948
949    // ── is_enum_closed tests ──────────────────────────────────────────────
950
951    #[test]
952    fn test_is_enum_closed_proto3_default_open() {
953        let files = [make_file("a.proto", "p", vec![], vec![enum_desc("E")])];
954        let config = CodeGenConfig::default();
955        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
956        // proto3 default (make_file has no syntax = proto2/implicit)
957        // actually make_file doesn't set syntax, so it's proto2 default...
958        // proto2 default is CLOSED.
959        assert_eq!(ctx.is_enum_closed(".p.E"), Some(true));
960    }
961
962    #[test]
963    fn test_is_enum_closed_editions_default_open() {
964        let files = [editions_file("a.proto", "p", vec![], vec![enum_desc("E")])];
965        let config = CodeGenConfig::default();
966        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
967        // Edition 2023 default is OPEN.
968        assert_eq!(ctx.is_enum_closed(".p.E"), Some(false));
969    }
970
971    #[test]
972    fn test_is_enum_closed_per_enum_override() {
973        // This is THE bug: enum with `option features.enum_type = CLOSED`
974        // in an otherwise-open editions file must be detected as closed.
975        let files = [editions_file(
976            "a.proto",
977            "p",
978            vec![],
979            vec![enum_desc("Open"), enum_with_closed_feature("Closed")],
980        )];
981        let config = CodeGenConfig::default();
982        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
983        assert_eq!(ctx.is_enum_closed(".p.Open"), Some(false));
984        assert_eq!(ctx.is_enum_closed(".p.Closed"), Some(true));
985    }
986
987    #[test]
988    fn test_is_enum_closed_nested_per_enum_override() {
989        // Feature resolution through file → message → enum.
990        let files = [editions_file(
991            "a.proto",
992            "p",
993            vec![msg_with_nested_and_enums(
994                "M",
995                vec![],
996                vec![enum_with_closed_feature("Inner")],
997            )],
998            vec![],
999        )];
1000        let config = CodeGenConfig::default();
1001        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1002        assert_eq!(ctx.is_enum_closed(".p.M.Inner"), Some(true));
1003    }
1004
1005    #[test]
1006    fn test_is_enum_closed_unknown_enum_returns_none() {
1007        let files = [editions_file("a.proto", "p", vec![], vec![])];
1008        let config = CodeGenConfig::default();
1009        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1010        // extern_path or missing enum → None (caller falls back).
1011        assert_eq!(ctx.is_enum_closed(".other.Unknown"), None);
1012    }
1013
1014    #[test]
1015    fn test_for_generate_auto_injects_wkt_mapping() {
1016        // for_generate() must produce the same type_map as generate() uses
1017        // internally — including the auto-injected WKT extern_path.
1018        let ts_msg = DescriptorProto {
1019            name: Some("Timestamp".into()),
1020            ..Default::default()
1021        };
1022        let files = [FileDescriptorProto {
1023            name: Some("google/protobuf/timestamp.proto".into()),
1024            package: Some("google.protobuf".into()),
1025            syntax: Some("proto3".into()),
1026            message_type: vec![ts_msg],
1027            ..Default::default()
1028        }];
1029        let config = CodeGenConfig::default();
1030        // Not generating the WKT file itself → auto-mapping should kick in.
1031        let ctx = CodeGenContext::for_generate(&files, &["other.proto".into()], &config);
1032        assert_eq!(
1033            ctx.rust_type(".google.protobuf.Timestamp"),
1034            Some("::buffa_types::google::protobuf::Timestamp"),
1035            "WKT auto-mapping must be applied via for_generate"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_for_generate_suppresses_wkt_when_generating_wkt() {
1041        // When files_to_generate includes a google.protobuf file (building
1042        // buffa-types itself), the WKT auto-mapping must NOT be applied.
1043        let ts_msg = DescriptorProto {
1044            name: Some("Timestamp".into()),
1045            ..Default::default()
1046        };
1047        let files = [FileDescriptorProto {
1048            name: Some("google/protobuf/timestamp.proto".into()),
1049            package: Some("google.protobuf".into()),
1050            syntax: Some("proto3".into()),
1051            message_type: vec![ts_msg],
1052            ..Default::default()
1053        }];
1054        let config = CodeGenConfig::default();
1055        let ctx = CodeGenContext::for_generate(
1056            &files,
1057            &["google/protobuf/timestamp.proto".into()],
1058            &config,
1059        );
1060        // No extern mapping → local-package path.
1061        assert_eq!(
1062            ctx.rust_type(".google.protobuf.Timestamp"),
1063            Some("google::protobuf::Timestamp")
1064        );
1065    }
1066}