1mod model;
57mod parse;
58
59use zyn::{
60 ToTokens as _,
61 proc_macro2::{Delimiter, Group, Ident, Literal, Span, TokenStream, TokenTree},
62 syn::LitStr,
63};
64
65use crate::model::{PiiVariantModel, RedactValue};
66
67struct PlaintextFieldData {
76 name_str: LitStr,
77 binding: Ident,
78}
79
80fn plaintext_field_data(variant: &PiiVariantModel, span: Span) -> Vec<PlaintextFieldData> {
81 variant
82 .plaintext_fields()
83 .map(|f| PlaintextFieldData {
84 name_str: LitStr::new(&f.ident.to_string(), span),
85 binding: zyn::format_ident!("{}_str", f.ident),
86 })
87 .collect()
88}
89
90fn redact_value_to_tokens(rv: &RedactValue, span: Span) -> TokenStream {
96 match rv {
97 RedactValue::Literal(s) => {
98 std::iter::once(TokenTree::Literal(Literal::string(s))).collect()
99 }
100 RedactValue::Null => std::iter::once(TokenTree::Ident(Ident::new("null", span))).collect(),
101 RedactValue::EmptyObject => std::iter::once(TokenTree::Group(Group::new(
102 Delimiter::Brace,
103 TokenStream::new(),
104 )))
105 .collect(),
106 RedactValue::EmptyArray => std::iter::once(TokenTree::Group(Group::new(
107 Delimiter::Bracket,
108 TokenStream::new(),
109 )))
110 .collect(),
111 }
112}
113
114struct SecretFieldData {
117 name_str: LitStr,
118 redact_ts: TokenStream,
120}
121
122fn secret_field_data(variant: &PiiVariantModel, span: Span) -> Vec<SecretFieldData> {
123 variant
124 .secret_fields()
125 .map(|f| SecretFieldData {
126 name_str: LitStr::new(&f.ident.to_string(), span),
127 redact_ts: redact_value_to_tokens(
128 f.redact
129 .as_ref()
130 .expect("secret fields always have a RedactValue"),
131 span,
132 ),
133 })
134 .collect()
135}
136
137#[zyn::element]
149fn classify_arm(variant: PiiVariantModel) -> zyn::TokenStream {
150 let span = Span::call_site();
151
152 let event_type = LitStr::new(&variant.event_type, span);
153 let sentinel = LitStr::new(&variant.sentinel, span);
154 let subject_str = LitStr::new(&variant.subject_field().ident.to_string(), span);
155
156 let plaintext = plaintext_field_data(variant, span);
157
158 let secret_strs: Vec<LitStr> = variant
160 .secret_fields()
161 .map(|f| LitStr::new(&f.ident.to_string(), span))
162 .collect();
163
164 let is_single = variant.is_single_secret();
165
166 let single_secret_str = is_single.then(|| {
168 LitStr::new(
169 &variant.secret_fields().next().unwrap().ident.to_string(),
170 span,
171 )
172 });
173
174 zyn::zyn! {
175 {{ event_type }} => {
176 let __key = {{ event_type }};
177 let __subject_id_str = event.payload[__key][{{ subject_str }}]
178 .as_str()?.to_string();
179 let __subject_id = ::uuid::Uuid::parse_str(&__subject_id_str).ok()?;
180
181 @for (pt in plaintext.iter()) {
187 let {{ pt.binding }} = event.payload[__key][{{ pt.name_str }}].clone();
188 }
189
190 @if (is_single) {
194 let __plaintext_pii =
195 event.payload[__key][{{ single_secret_str.as_ref().unwrap() }}].clone();
196 } @else {
197 let __inner = event.payload[__key].as_object()?;
198 let __plaintext_pii = ::serde_json::json!({
199 @for (s in secret_strs.iter()) {
200 {{ s }}: __inner.get({{ s }})
201 .cloned()
202 .unwrap_or(::serde_json::Value::Null),
203 }
204 });
205 }
206
207 ::core::option::Option::Some(::cqrs_es_crypto::PiiFields {
208 subject_id: __subject_id,
209 plaintext_pii: __plaintext_pii,
210 build_encrypted_payload: ::std::boxed::Box::new(move |__sentinel| {
211 ::serde_json::json!({
212 {{ event_type }}: {
213 @for (pt in plaintext.iter()) {
214 {{ pt.name_str }}: {{ pt.binding }},
215 }
216 {{ subject_str }}: __subject_id_str,
217 {{ sentinel }}: __sentinel.ciphertext_b64,
218 "nonce": __sentinel.nonce_b64,
219 }
220 })
221 }),
222 })
223 }
224 }
225}
226
227#[zyn::element]
234fn extract_arm(variant: PiiVariantModel) -> zyn::TokenStream {
235 let span = Span::call_site();
236
237 let event_type = LitStr::new(&variant.event_type, span);
238 let sentinel = LitStr::new(&variant.sentinel, span);
239 let subject_str = LitStr::new(&variant.subject_field().ident.to_string(), span);
240
241 zyn::zyn! {
242 {{ event_type }} => {
243 let __key = {{ event_type }};
244 event.payload[__key].get({{ sentinel }})?;
246 let __subject_id = ::uuid::Uuid::parse_str(
247 event.payload[__key][{{ subject_str }}].as_str()?
248 ).ok()?;
249 let __ciphertext = <::base64::engine::general_purpose::GeneralPurpose
250 as ::base64::Engine>::decode(
251 &::base64::engine::general_purpose::STANDARD,
252 event.payload[__key][{{ sentinel }}].as_str()?,
253 ).ok()?;
254 let __nonce = <::base64::engine::general_purpose::GeneralPurpose
255 as ::base64::Engine>::decode(
256 &::base64::engine::general_purpose::STANDARD,
257 event.payload[__key]["nonce"].as_str()?,
258 ).ok()?;
259 ::core::option::Option::Some(::cqrs_es_crypto::EncryptedPiiExtract {
260 subject_id: __subject_id,
261 ciphertext: __ciphertext,
262 nonce: __nonce,
263 })
264 }
265 }
266}
267
268#[zyn::element]
279fn reconstruct_arm(variant: PiiVariantModel) -> zyn::TokenStream {
280 let span = Span::call_site();
281
282 let event_type = LitStr::new(&variant.event_type, span);
283 let subject_str = LitStr::new(&variant.subject_field().ident.to_string(), span);
284 let plaintext = plaintext_field_data(variant, span);
285
286 let secret_strs: Vec<LitStr> = variant
287 .secret_fields()
288 .map(|f| LitStr::new(&f.ident.to_string(), span))
289 .collect();
290
291 let is_single = variant.is_single_secret();
292
293 let single_secret_str = is_single.then(|| {
294 LitStr::new(
295 &variant.secret_fields().next().unwrap().ident.to_string(),
296 span,
297 )
298 });
299
300 zyn::zyn! {
301 {{ event_type }} => {
302 let __key = {{ event_type }};
303 @for (pt in plaintext.iter()) {
304 let {{ pt.binding }} = event.payload[__key][{{ pt.name_str }}].clone();
305 }
306 let __subject_id_val = event.payload[__key][{{ subject_str }}].clone();
307 ::core::result::Result::Ok(::serde_json::json!({
308 {{ event_type }}: {
309 @for (pt in plaintext.iter()) {
310 {{ pt.name_str }}: {{ pt.binding }},
311 }
312 {{ subject_str }}: __subject_id_val,
313 @if (is_single) {
314 {{ single_secret_str.as_ref().unwrap() }}: plaintext_pii,
315 } @else {
316 @for (s in secret_strs.iter()) {
317 {{ s }}: plaintext_pii[{{ s }}],
318 }
319 }
320 }
321 }))
322 }
323 }
324}
325
326#[zyn::element]
338fn redact_arm(variant: PiiVariantModel) -> zyn::TokenStream {
339 let span = Span::call_site();
340
341 let event_type = LitStr::new(&variant.event_type, span);
342 let subject_str = LitStr::new(&variant.subject_field().ident.to_string(), span);
343 let plaintext = plaintext_field_data(variant, span);
344 let secrets = secret_field_data(variant, span);
345
346 zyn::zyn! {
347 {{ event_type }} => {
348 let __key = {{ event_type }};
349 @for (pt in plaintext.iter()) {
350 let {{ pt.binding }} = event.payload[__key][{{ pt.name_str }}].clone();
351 }
352 let __subject_id_val = event.payload[__key][{{ subject_str }}].clone();
353 ::core::result::Result::Ok(::serde_json::json!({
354 {{ event_type }}: {
355 @for (pt in plaintext.iter()) {
356 {{ pt.name_str }}: {{ pt.binding }},
357 }
358 {{ subject_str }}: __subject_id_val,
359 @for (sf in secrets.iter()) {
360 {{ sf.name_str }}: {{ sf.redact_ts }},
361 }
362 }
363 }))
364 }
365 }
366}
367
368#[zyn::derive("PiiCodec", attributes(pii))]
375fn pii_codec(
376 #[zyn(input)] ident: zyn::Extract<Ident>,
377 #[zyn(input)] variants: zyn::Variants,
378) -> zyn::TokenStream {
379 let codec_ident = zyn::format_ident!("{}PiiCodec", *ident);
380
381 let pii_variants: Vec<PiiVariantModel> = match parse::parse_pii_variants(&variants) {
384 Ok(v) => v,
385 Err(e) => return e.into_compile_error().into(),
386 };
387
388 zyn::zyn! {
389 pub struct {{ codec_ident }};
392
393 impl ::cqrs_es_crypto::PiiEventCodec for {{ codec_ident }} {
394 fn classify(
395 &self,
396 event: &::cqrs_es::persist::SerializedEvent,
397 ) -> ::core::option::Option<::cqrs_es_crypto::PiiFields> {
398 match event.event_type.as_str() {
399 @for (v in pii_variants.iter()) {
400 @classify_arm(variant = v.clone())
401 }
402 _ => ::core::option::Option::None,
403 }
404 }
405
406 fn extract_encrypted(
407 &self,
408 event: &::cqrs_es::persist::SerializedEvent,
409 ) -> ::core::option::Option<::cqrs_es_crypto::EncryptedPiiExtract> {
410 match event.event_type.as_str() {
411 @for (v in pii_variants.iter()) {
412 @extract_arm(variant = v.clone())
413 }
414 _ => ::core::option::Option::None,
415 }
416 }
417
418 fn reconstruct(
419 &self,
420 event: &::cqrs_es::persist::SerializedEvent,
421 plaintext_pii: &::serde_json::Value,
422 ) -> ::core::result::Result<
423 ::serde_json::Value,
424 ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>,
425 > {
426 match event.event_type.as_str() {
427 @for (v in pii_variants.iter()) {
428 @reconstruct_arm(variant = v.clone())
429 }
430 _ => ::core::result::Result::Ok(event.payload.clone()),
431 }
432 }
433
434 fn redact(
435 &self,
436 event: &::cqrs_es::persist::SerializedEvent,
437 ) -> ::core::result::Result<
438 ::serde_json::Value,
439 ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>,
440 > {
441 match event.event_type.as_str() {
442 @for (v in pii_variants.iter()) {
443 @redact_arm(variant = v.clone())
444 }
445 _ => ::core::result::Result::Ok(event.payload.clone()),
446 }
447 }
448 }
449 }
450 .to_token_stream()
451}
452
453#[cfg(test)]
456mod tests {
457 use crate::model::{PiiFieldModel, PiiFieldRole, PiiVariantModel, RedactValue};
458
459 use super::*;
460
461 fn dummy_input() -> zyn::Input {
464 zyn::parse!("enum TestEvent {}" => zyn::syn::DeriveInput)
465 .unwrap()
466 .into()
467 }
468
469 fn field(name: &str, role: PiiFieldRole) -> PiiFieldModel {
470 PiiFieldModel {
471 ident: zyn::format_ident!("{}", name),
472 role,
473 redact: None,
474 }
475 }
476
477 fn str_secret_field(name: &str) -> PiiFieldModel {
478 PiiFieldModel {
479 ident: zyn::format_ident!("{}", name),
480 role: PiiFieldRole::Secret,
481 redact: Some(RedactValue::Literal("[redacted]".to_string())),
482 }
483 }
484
485 fn value_secret_field(name: &str) -> PiiFieldModel {
486 PiiFieldModel {
487 ident: zyn::format_ident!("{}", name),
488 role: PiiFieldRole::Secret,
489 redact: Some(RedactValue::EmptyObject),
490 }
491 }
492
493 fn vec_secret_field(name: &str) -> PiiFieldModel {
494 PiiFieldModel {
495 ident: zyn::format_ident!("{}", name),
496 role: PiiFieldRole::Secret,
497 redact: Some(RedactValue::EmptyArray),
498 }
499 }
500
501 fn multi_secret_variant() -> PiiVariantModel {
504 PiiVariantModel {
505 event_type: "PersonCaptured".to_string(),
506 sentinel: "encrypted_pii".to_string(),
507 fields: vec![
508 field("person_ref", PiiFieldRole::Plaintext),
509 field("subject_id", PiiFieldRole::Subject),
510 str_secret_field("name"),
511 str_secret_field("email"),
512 str_secret_field("phone"),
513 ],
514 }
515 }
516
517 fn single_secret_variant() -> PiiVariantModel {
520 PiiVariantModel {
521 event_type: "PersonDetailsUpdated".to_string(),
522 sentinel: "encrypted_data".to_string(),
523 fields: vec![
524 field("person_ref", PiiFieldRole::Plaintext),
525 field("subject_id", PiiFieldRole::Subject),
526 value_secret_field("data"),
527 ],
528 }
529 }
530
531 fn single_secret_vec_variant() -> PiiVariantModel {
534 PiiVariantModel {
535 event_type: "PhonesCaptured".to_string(),
536 sentinel: "encrypted_pii".to_string(),
537 fields: vec![
538 field("person_ref", PiiFieldRole::Plaintext),
539 field("subject_id", PiiFieldRole::Subject),
540 vec_secret_field("phone_numbers"),
541 ],
542 }
543 }
544
545 fn multi_secret_variant_with_custom_subject() -> PiiVariantModel {
550 PiiVariantModel {
551 event_type: "PersonCaptured".to_string(),
552 sentinel: "encrypted_pii".to_string(),
553 fields: vec![
554 field("person_ref", PiiFieldRole::Plaintext),
555 field("user_id", PiiFieldRole::Subject),
556 str_secret_field("name"),
557 str_secret_field("email"),
558 ],
559 }
560 }
561
562 #[test]
565 fn classify_arm_emits_event_type_match_pattern() {
566 let input = dummy_input();
567 let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
568 zyn::assert_tokens_contain!(output, "\"PersonCaptured\"");
569 }
570
571 #[test]
572 fn classify_arm_extracts_subject_id_str() {
573 let input = dummy_input();
574 let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
575 zyn::assert_tokens_contain!(output, "__subject_id_str");
576 zyn::assert_tokens_contain!(output, "\"subject_id\"");
577 }
578
579 #[test]
580 fn classify_arm_extracts_plaintext_fields_into_bindings() {
581 let input = dummy_input();
582 let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
583 zyn::assert_tokens_contain!(output, "person_ref_str");
585 zyn::assert_tokens_contain!(output, "\"person_ref\"");
586 }
587
588 #[test]
589 fn classify_arm_clones_plaintext_fields_to_preserve_json_type() {
590 let input = dummy_input();
600 let output = zyn::zyn!(@classify_arm(variant = single_secret_variant()));
601 let raw = output.to_string();
602 assert!(
603 !raw.contains("unwrap_or"),
604 "classify_arm must not coerce plaintext fields via `unwrap_or`, got: {raw}"
605 );
606 }
607
608 #[test]
609 fn classify_arm_multi_secret_bundles_into_object() {
610 let input = dummy_input();
611 let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
612 zyn::assert_tokens_contain!(output, "__inner");
614 zyn::assert_tokens_contain!(output, "\"name\"");
615 zyn::assert_tokens_contain!(output, "\"email\"");
616 zyn::assert_tokens_contain!(output, "\"phone\"");
617 }
618
619 #[test]
620 fn classify_arm_single_secret_uses_direct_value() {
621 let input = dummy_input();
622 let output = zyn::zyn!(@classify_arm(variant = single_secret_variant()));
623 let raw = output.to_string();
625 assert!(
626 !raw.contains("__inner"),
627 "single-secret classify arm must not bundle into a JSON object"
628 );
629 zyn::assert_tokens_contain!(output, "\"data\"");
631 }
632
633 #[test]
634 fn classify_arm_uses_default_sentinel_in_closure() {
635 let input = dummy_input();
636 let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
637 zyn::assert_tokens_contain!(output, "\"encrypted_pii\"");
638 }
639
640 #[test]
641 fn classify_arm_uses_custom_sentinel_in_closure() {
642 let input = dummy_input();
643 let output = zyn::zyn!(@classify_arm(variant = single_secret_variant()));
644 zyn::assert_tokens_contain!(output, "\"encrypted_data\"");
645 }
646
647 #[test]
648 fn classify_arm_closure_captures_plaintext_bindings() {
649 let input = dummy_input();
650 let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
651 zyn::assert_tokens_contain!(output, "build_encrypted_payload");
653 zyn::assert_tokens_contain!(output, "__subject_id_str");
654 }
655
656 #[test]
657 fn classify_arm_emits_pii_fields_struct() {
658 let input = dummy_input();
659 let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
660 zyn::assert_tokens_contain!(output, "PiiFields");
661 zyn::assert_tokens_contain!(output, "subject_id");
662 zyn::assert_tokens_contain!(output, "plaintext_pii");
663 }
664
665 #[test]
666 fn classify_arm_uses_custom_subject_name_in_both_read_and_write() {
667 let input = dummy_input();
672 let output = zyn::zyn!(
673 @classify_arm(variant = multi_secret_variant_with_custom_subject())
674 );
675 let raw = output.to_string();
676
677 assert!(
681 raw.contains("\"user_id\""),
682 "classify_arm must use the variant's actual subject field name as the JSON key, got: {raw}"
683 );
684
685 assert!(
688 !raw.contains("\"subject_id\""),
689 "classify_arm must not emit hardcoded \"subject_id\" when the variant uses a different subject name, got: {raw}"
690 );
691 }
692
693 #[test]
696 fn extract_arm_emits_event_type_match_pattern() {
697 let input = dummy_input();
698 let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
699 zyn::assert_tokens_contain!(output, "\"PersonCaptured\"");
700 }
701
702 #[test]
703 fn extract_arm_checks_sentinel_presence() {
704 let input = dummy_input();
705 let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
706 zyn::assert_tokens_contain!(output, "\"encrypted_pii\"");
708 zyn::assert_tokens_contain!(output, "get");
709 }
710
711 #[test]
712 fn extract_arm_uses_custom_sentinel() {
713 let input = dummy_input();
714 let output = zyn::zyn!(@extract_arm(variant = single_secret_variant()));
715 zyn::assert_tokens_contain!(output, "\"encrypted_data\"");
716 }
717
718 #[test]
719 fn extract_arm_extracts_subject_id() {
720 let input = dummy_input();
721 let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
722 zyn::assert_tokens_contain!(output, "__subject_id");
723 zyn::assert_tokens_contain!(output, "\"subject_id\"");
724 }
725
726 #[test]
727 fn extract_arm_base64_decodes_ciphertext_and_nonce() {
728 let input = dummy_input();
729 let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
730 zyn::assert_tokens_contain!(output, "STANDARD");
731 zyn::assert_tokens_contain!(output, "__ciphertext");
732 zyn::assert_tokens_contain!(output, "__nonce");
733 zyn::assert_tokens_contain!(output, "\"nonce\"");
734 }
735
736 #[test]
737 fn extract_arm_emits_encrypted_pii_extract_struct() {
738 let input = dummy_input();
739 let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
740 zyn::assert_tokens_contain!(output, "EncryptedPiiExtract");
741 zyn::assert_tokens_contain!(output, "ciphertext");
742 zyn::assert_tokens_contain!(output, "nonce");
743 }
744
745 #[test]
748 fn reconstruct_arm_emits_event_type_match_pattern() {
749 let input = dummy_input();
750 let output = zyn::zyn!(@reconstruct_arm(variant = multi_secret_variant()));
751 zyn::assert_tokens_contain!(output, "\"PersonCaptured\"");
752 }
753
754 #[test]
755 fn reconstruct_arm_contains_subject_field_name_and_binding() {
756 let input = dummy_input();
757 let output = zyn::zyn!(@reconstruct_arm(variant = multi_secret_variant()));
758 zyn::assert_tokens_contain!(output, "\"subject_id\"");
759 zyn::assert_tokens_contain!(output, "__subject_id_val");
760 }
761
762 #[test]
763 fn reconstruct_arm_contains_plaintext_binding() {
764 let input = dummy_input();
765 let output = zyn::zyn!(@reconstruct_arm(variant = multi_secret_variant()));
766 zyn::assert_tokens_contain!(output, "person_ref_str");
767 zyn::assert_tokens_contain!(output, "\"person_ref\"");
768 }
769
770 #[test]
771 fn reconstruct_arm_multi_secret_contains_indexed_plaintext_pii() {
772 let input = dummy_input();
773 let output = zyn::zyn!(@reconstruct_arm(variant = multi_secret_variant()));
774 zyn::assert_tokens_contain!(output, "plaintext_pii");
776 zyn::assert_tokens_contain!(output, "\"name\"");
777 zyn::assert_tokens_contain!(output, "\"email\"");
778 zyn::assert_tokens_contain!(output, "\"phone\"");
779 }
780
781 #[test]
782 fn reconstruct_arm_single_secret_uses_plaintext_pii_directly() {
783 let input = dummy_input();
784 let output = zyn::zyn!(@reconstruct_arm(variant = single_secret_variant()));
785 let raw = output.to_string();
787 assert!(
788 raw.contains("plaintext_pii"),
789 "should reference plaintext_pii, got: {raw}"
790 );
791 assert!(
792 !raw.contains("plaintext_pii [") && !raw.contains("plaintext_pii["),
793 "single-secret reconstruct must not index into plaintext_pii, got: {raw}"
794 );
795 zyn::assert_tokens_contain!(output, "\"data\"");
796 }
797
798 #[test]
799 fn reconstruct_arm_single_secret_emits_event_type() {
800 let input = dummy_input();
801 let output = zyn::zyn!(@reconstruct_arm(variant = single_secret_variant()));
802 zyn::assert_tokens_contain!(output, "\"PersonDetailsUpdated\"");
803 }
804
805 #[test]
808 fn redact_arm_emits_event_type_match_pattern() {
809 let input = dummy_input();
810 let output = zyn::zyn!(@redact_arm(variant = multi_secret_variant()));
811 zyn::assert_tokens_contain!(output, "\"PersonCaptured\"");
812 }
813
814 #[test]
815 fn redact_arm_contains_subject_field_and_plaintext_binding() {
816 let input = dummy_input();
817 let output = zyn::zyn!(@redact_arm(variant = multi_secret_variant()));
818 zyn::assert_tokens_contain!(output, "__subject_id_val");
819 zyn::assert_tokens_contain!(output, "person_ref_str");
820 }
821
822 #[test]
823 fn redact_arm_multi_secret_emits_redacted_literal_for_string_fields() {
824 let input = dummy_input();
825 let output = zyn::zyn!(@redact_arm(variant = multi_secret_variant()));
826 zyn::assert_tokens_contain!(output, "\"[redacted]\"");
828 zyn::assert_tokens_contain!(output, "\"name\"");
829 zyn::assert_tokens_contain!(output, "\"email\"");
830 zyn::assert_tokens_contain!(output, "\"phone\"");
831 }
832
833 #[test]
834 fn redact_arm_single_secret_emits_empty_object_for_value_field() {
835 let input = dummy_input();
836 let output = zyn::zyn!(@redact_arm(variant = single_secret_variant()));
837 let raw = output.to_string();
839 assert!(
840 raw.contains("{ }") || raw.contains("{}"),
841 "single-secret redact arm should contain empty object `{{}}`, got: {raw}"
842 );
843 zyn::assert_tokens_contain!(output, "\"data\"");
844 }
845
846 #[test]
847 fn redact_arm_single_secret_emits_event_type() {
848 let input = dummy_input();
849 let output = zyn::zyn!(@redact_arm(variant = single_secret_variant()));
850 zyn::assert_tokens_contain!(output, "\"PersonDetailsUpdated\"");
851 }
852
853 #[test]
854 fn redact_arm_single_secret_emits_empty_array_for_vec_field() {
855 let input = dummy_input();
859 let output = zyn::zyn!(@redact_arm(variant = single_secret_vec_variant()));
860 let raw = output.to_string();
861 assert!(
862 raw.contains("[ ]") || raw.contains("[]"),
863 "single-secret Vec redact arm should contain empty array `[]`, got: {raw}"
864 );
865 zyn::assert_tokens_contain!(output, "\"phone_numbers\"");
866 }
867}