use super::*;
fn squash(content: &str) -> String {
content.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn fixture() -> FileDescriptorProto {
let mut file = proto3_file("gated.proto");
file.package = Some("pkg".to_string());
file.enum_type.push(EnumDescriptorProto {
name: Some("Color".to_string()),
value: vec![enum_value("RED", 0), enum_value("BLUE", 1)],
..Default::default()
});
file.message_type.push(DescriptorProto {
name: Some("Outer".to_string()),
field: vec![
FieldDescriptorProto {
name: Some("name".to_string()),
number: Some(1),
label: Some(Label::LABEL_OPTIONAL),
r#type: Some(Type::TYPE_STRING),
json_name: Some("name".to_string()),
..Default::default()
},
FieldDescriptorProto {
name: Some("color".to_string()),
number: Some(2),
label: Some(Label::LABEL_OPTIONAL),
r#type: Some(Type::TYPE_ENUM),
type_name: Some(".pkg.Color".to_string()),
json_name: Some("color".to_string()),
..Default::default()
},
FieldDescriptorProto {
name: Some("which".to_string()),
number: Some(3),
label: Some(Label::LABEL_OPTIONAL),
r#type: Some(Type::TYPE_INT32),
oneof_index: Some(0),
json_name: Some("which".to_string()),
..Default::default()
},
],
oneof_decl: vec![OneofDescriptorProto {
name: Some("kind".to_string()),
..Default::default()
}],
nested_type: vec![DescriptorProto {
name: Some("Inner".to_string()),
..Default::default()
}],
..Default::default()
});
file.message_type.push(DescriptorProto {
name: Some("Extendable".to_string()),
extension_range: vec![
crate::generated::descriptor::descriptor_proto::ExtensionRange {
start: Some(100),
end: Some(200),
..Default::default()
},
],
..Default::default()
});
file
}
fn generate_gated(generate_text: bool) -> String {
let cfg = CodeGenConfig {
generate_json: true,
generate_views: true,
generate_text,
preserve_unknown_fields: true,
gate_impls_on_crate_features: true,
..CodeGenConfig::default()
};
let files =
generate(&[fixture()], &["gated.proto".to_string()], &cfg).expect("should generate");
joined(&files)
}
fn generate_ungated() -> String {
let cfg = CodeGenConfig {
generate_json: true,
generate_views: true,
generate_text: true,
preserve_unknown_fields: true,
gate_impls_on_crate_features: false,
..CodeGenConfig::default()
};
let files =
generate(&[fixture()], &["gated.proto".to_string()], &cfg).expect("should generate");
joined(&files)
}
#[test]
fn ungated_output_has_no_feature_cfgs() {
let content = generate_ungated();
assert!(
!content.contains(r#"feature = "json""#),
"ungated output must not gate on the json feature: {content}"
);
assert!(
!content.contains(r#"feature = "views""#),
"ungated output must not gate on the views feature: {content}"
);
assert!(
!content.contains(r#"feature = "text""#),
"ungated output must not gate on the text feature: {content}"
);
}
#[test]
fn gated_message_serde_derive_is_cfg_attr() {
let content = generate_gated(false);
assert!(
content.contains(r#"#[cfg_attr(feature = "json", derive(::serde::Serialize"#),
"struct serde derive must be cfg_attr-gated: {content}"
);
assert!(
content.contains(r#"#[cfg_attr(feature = "json", serde(default))]"#),
"struct serde(default) must be cfg_attr-gated: {content}"
);
let squashed = squash(&content);
assert!(
squashed.contains(r#"#[cfg_attr( feature = "json", serde( rename"#),
"field serde attrs must be cfg_attr-gated: {content}"
);
assert!(
content.contains(r#"#[cfg_attr(feature = "json", serde(flatten))]"#),
"oneof serde(flatten) must be cfg_attr-gated: {content}"
);
assert!(
content.contains(r#"#[cfg_attr(feature = "json", serde(skip))]"#),
"unknown-fields serde(skip) must be cfg_attr-gated: {content}"
);
assert!(
!content.contains(r#"#[serde("#),
"no serde attribute may be emitted ungated when gating is on: {content}"
);
assert!(
!content.contains(r#"#[derive(::serde::"#),
"no serde derive may be emitted ungated when gating is on: {content}"
);
}
#[test]
fn gated_enum_serde_impls_are_cfg_blocked() {
let content = generate_gated(false);
assert!(
content.contains(r#"#[cfg(feature = "json")]"#),
"enum serde impls must be cfg-gated: {content}"
);
assert!(
content.contains("impl ::buffa::Enumeration for Color"),
"Enumeration impl must remain unconditional: {content}"
);
}
#[test]
fn gated_view_module_is_cfg_blocked() {
let content = generate_gated(false);
assert!(
content.contains(r#"#[cfg(feature = "views")]"#),
"view module must be cfg-gated: {content}"
);
let squashed = squash(&content);
assert!(
squashed.contains(r#"#[cfg(feature = "views")] pub mod view"#),
"the views cfg must precede `pub mod view`: {content}"
);
let cfg = CodeGenConfig {
generate_json: true,
generate_views: true,
generate_text: false,
preserve_unknown_fields: true,
gate_impls_on_crate_features: true,
..CodeGenConfig::default()
};
let files =
generate(&[fixture()], &["gated.proto".to_string()], &cfg).expect("should generate");
for f in &files {
let has_view_impl = f.content.contains("MessageName for OuterView");
let has_owned_impl = squash(&f.content).contains("impl ::buffa::MessageName for Outer ");
match f.kind {
GeneratedFileKind::View => assert!(
has_view_impl,
"view MessageName impl must be in the view content file ({})",
f.name
),
GeneratedFileKind::Owned => assert!(
has_owned_impl && !has_view_impl,
"owned content file must have the owned (not view) MessageName impl ({})",
f.name
),
_ => assert!(
!has_view_impl && !has_owned_impl,
"MessageName impl leaked into a {:?} file ({})",
f.kind,
f.name
),
}
}
}
#[test]
fn gated_view_reexports_are_cfg_blocked() {
let content = generate_gated(false);
let squashed = squash(&content);
let re_exports: Vec<&str> = squashed
.match_indices("pub use ")
.map(|(i, _)| &squashed[i..(i + 80).min(squashed.len())])
.filter(|s| s.contains("__buffa::view::"))
.collect();
assert!(
!re_exports.is_empty(),
"fixture must produce at least one view re-export: {content}"
);
for re in &re_exports {
let prefix = format!(
r#"#[cfg(feature = "views")] #[doc(inline)] {}"#,
&re[..re.find('`').unwrap_or(re.len()).min(40)]
);
let alt = format!(r#"#[cfg(feature = "views")] {}"#, &re[..40.min(re.len())]);
assert!(
squashed.contains(&prefix) || squashed.contains(&alt),
"view re-export `{re}` must be gated on `feature = \"views\"`: {content}"
);
}
}
#[test]
fn gated_text_impl_is_cfg_blocked() {
let content = generate_gated(true);
assert!(
content.contains(r#"#[cfg(feature = "text")]"#),
"TextFormat impl must be cfg-gated: {content}"
);
assert!(
content.contains("impl ::buffa::Message for Outer"),
"Message impl must remain unconditional: {content}"
);
}
#[test]
fn gated_register_types_statements_are_cfg_blocked() {
let content = generate_gated(true);
let squashed = squash(&content);
assert!(
squashed.contains(r#"#[cfg(feature = "json")] reg.register_json_any"#),
"register_json_any statement must be cfg-gated: {content}"
);
assert!(
squashed.contains(r#"#[cfg(feature = "text")] reg.register_text_any"#),
"register_text_any statement must be cfg-gated: {content}"
);
let any_gate = r#"#[cfg(any(feature = "json", feature = "text"))]"#;
assert_eq!(
squashed.matches("register_types").count(),
squashed.matches(any_gate).count(),
"every register_types occurrence must carry an any(json, text) gate: {content}"
);
assert!(
squashed.contains(any_gate),
"register_types must be gated on any(json, text): {content}"
);
assert!(
content.contains("#[allow(unused_variables)]"),
"register_types must allow unused `reg` for partial-feature builds: {content}"
);
}
#[test]
fn gated_register_types_with_json_only() {
let content = generate_gated(false);
let squashed = squash(&content);
assert!(
!squashed.contains(r#"any(feature"#),
"json-only register_types must be gated on a single feature, not any(): {content}"
);
}
#[test]
fn gated_ext_json_wrapper_struct_is_unconditional_but_serde_impls_are_gated() {
let content = generate_gated(false);
assert!(
content.contains("pub struct __ExtendableExtJson"),
"ext-json wrapper struct must be unconditional: {content}"
);
assert!(
content.contains("impl ::core::ops::Deref for __ExtendableExtJson"),
"ext-json wrapper Deref must be unconditional: {content}"
);
let squashed = squash(&content);
assert!(
squashed.contains(
r#"#[cfg(feature = "json")] impl ::serde::Serialize for __ExtendableExtJson"#
),
"ext-json wrapper Serialize must be cfg-gated: {content}"
);
assert!(
squashed.contains(
r#"#[cfg(feature = "json")] impl<'de> ::serde::Deserialize<'de> for __ExtendableExtJson"#
),
"ext-json wrapper Deserialize must be cfg-gated: {content}"
);
}
#[test]
fn gated_only_when_kind_is_enabled() {
let content = generate_gated(false);
assert!(
!content.contains(r#"feature = "text""#),
"text feature must not appear when generate_text is off: {content}"
);
assert!(
!content.contains("TextFormat"),
"no TextFormat impls when generate_text is off: {content}"
);
}
#[test]
fn gating_with_json_disabled_emits_no_json_gates_or_impls() {
let cfg = CodeGenConfig {
generate_json: false,
generate_views: true,
generate_text: false,
preserve_unknown_fields: true,
gate_impls_on_crate_features: true,
..CodeGenConfig::default()
};
let files =
generate(&[fixture()], &["gated.proto".to_string()], &cfg).expect("should generate");
let content = joined(&files);
assert!(
!content.contains(r#"feature = "json""#),
"json feature gate must not appear when generate_json is off: {content}"
);
assert!(
!content.contains("::serde::"),
"no serde impls when generate_json is off: {content}"
);
assert!(
content.contains(r#"#[cfg(feature = "views")]"#),
"views must still be gated when on: {content}"
);
}
#[test]
fn gated_output_parses_as_valid_rust() {
for generate_text in [false, true] {
let cfg = CodeGenConfig {
generate_json: true,
generate_views: true,
generate_text,
preserve_unknown_fields: true,
gate_impls_on_crate_features: true,
..CodeGenConfig::default()
};
let files =
generate(&[fixture()], &["gated.proto".to_string()], &cfg).expect("should generate");
for f in &files {
syn::parse_file(&f.content).unwrap_or_else(|e| {
panic!(
"gated output for {} (text={generate_text}) is not valid Rust: {e}\n{}",
f.name, f.content
)
});
}
}
}