redactable-derive 0.11.0

Derive macros for the redactable crate
Documentation
//! Container-level attribute parsing for `#[derive(Sensitive)]` and `#[derive(SensitiveDisplay)]`.
//!
//! This module handles attributes on the struct/enum itself, not on fields.

use syn::{Attribute, Meta, Result};

/// Rejects field-only helpers when they are attached to a derived container.
pub(crate) fn reject_field_only_container_attrs(attrs: &[Attribute]) -> Result<()> {
    for attr in attrs {
        if attr.path().is_ident("not_sensitive") {
            return Err(syn::Error::new_spanned(
                attr,
                "`#[not_sensitive]` is only supported on fields; derive `NotSensitive` or `NotSensitiveDisplay` to classify the complete type",
            ));
        }
        if attr.path().is_ident("redactable") {
            return Err(syn::Error::new_spanned(
                attr,
                "`#[redactable(...)]` is only supported on fields; annotate the specific recursive or legacy-formatted field",
            ));
        }
    }
    Ok(())
}

/// Options parsed from container-level `#[sensitive(...)]` attributes.
///
/// Both `Sensitive` and `SensitiveDisplay` read these options.
#[derive(Clone, Debug, Default)]
pub(crate) struct ContainerOptions {
    /// If true, this type derives both `Sensitive` and `SensitiveDisplay`.
    ///
    /// Each macro adjusts its output to avoid conflicting impls:
    /// - `Sensitive` skips `Debug` (lets `SensitiveDisplay` provide it).
    /// - `SensitiveDisplay` skips `slog` and `tracing` (lets `Sensitive` provide them).
    pub(crate) dual: bool,
}

/// Parses container-level `#[sensitive(...)]` attributes.
pub(crate) fn parse_container_options(attrs: &[Attribute]) -> Result<ContainerOptions> {
    let mut options = ContainerOptions::default();

    for attr in attrs {
        if !attr.path().is_ident("sensitive") {
            continue;
        }

        match &attr.meta {
            Meta::Path(_) => {
                return Err(syn::Error::new_spanned(
                    attr,
                    "bare `#[sensitive]` on the container has no effect; \
                     use `#[derive(SensitiveDual)]` when structural and display redaction are both needed",
                ));
            }
            Meta::List(list) => {
                list.parse_nested_meta(|meta| {
                    if meta.path.is_ident("dual") {
                        options.dual = true;
                        Ok(())
                    } else {
                        Err(meta.error(format!(
                            "unknown container option `{}`; expected `dual`",
                            meta.path
                                .get_ident()
                                .map_or_else(|| "?".to_string(), ToString::to_string)
                        )))
                    }
                })?;
            }
            Meta::NameValue(nv) => {
                return Err(syn::Error::new_spanned(
                    nv,
                    "name-value syntax is not supported for container-level #[sensitive]",
                ));
            }
        }
    }

    Ok(options)
}

#[cfg(test)]
mod tests {
    use quote::quote;
    use syn::DeriveInput;

    use super::*;

    fn parse_attrs(tokens: proc_macro2::TokenStream) -> Vec<Attribute> {
        let input: DeriveInput = syn::parse2(quote! {
            #tokens
            struct Dummy;
        })
        .expect("should parse as DeriveInput");
        input.attrs
    }

    #[test]
    fn no_attribute_returns_defaults() {
        let attrs = parse_attrs(quote! {});
        let options = parse_container_options(&attrs).unwrap();
        assert!(!options.dual);
    }

    #[test]
    fn field_only_helpers_are_rejected_on_containers() {
        let attrs = parse_attrs(quote! { #[not_sensitive] });
        assert!(reject_field_only_container_attrs(&attrs).is_err());

        let attrs = parse_attrs(quote! { #[redactable(recursive)] });
        assert!(reject_field_only_container_attrs(&attrs).is_err());
    }

    #[test]
    fn dual_is_parsed() {
        let attrs = parse_attrs(quote! { #[sensitive(dual)] });
        let options = parse_container_options(&attrs).unwrap();
        assert!(options.dual);
    }

    #[test]
    fn unknown_option_errors() {
        let attrs = parse_attrs(quote! { #[sensitive(unknown_option)] });
        let result = parse_container_options(&attrs);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("unknown container option")
        );
    }

    #[test]
    fn bare_sensitive_on_container_is_rejected() {
        let attrs = parse_attrs(quote! { #[sensitive] });
        let result = parse_container_options(&attrs);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("bare `#[sensitive]` on the container has no effect")
        );
    }
}