securefmt 0.1.5

Drop-in replacement for the Debug derive macro that hides fields marked as sensitive.
Documentation
use crate::{
    errors::Result,
    formatting_data::{
        FieldFormattingData, FormattingData, FormattingDataSource, VariantFormattingData,
    },
    implementation::FmtBodySource,
};
use proc_macro2::{Ident, TokenStream};
use quote::{format_ident, quote};
use std::{iter::Enumerate, slice::Iter};
use syn::{parse_str, Index, Path};

impl<T: FormattingDataSource> FmtBodySource for T {
    fn generate_fmt_body(&self, ident: &str) -> Result<TokenStream> {
        let formatting_data = self.to_formatting_data()?;
        let fmt_body = match formatting_data {
            FormattingData::StructData(fields) => generate_struct_fmt_body(ident, &fields),
            FormattingData::EnumData(variants) => generate_enum_fmt_body(ident, &variants),
        };
        Ok(fmt_body)
    }
}

fn generate_struct_fmt_body(ident: &str, fields: &[FieldFormattingData]) -> TokenStream {
    if fields.is_empty() {
        quote! { write!(f, #ident) }
    } else {
        generate_write_invocation(
            ident,
            &fields.iter().enumerate(),
            |idx: usize, ident: std::option::Option<proc_macro2::Ident>| {
                generate_struct_accessor(idx, &ident)
            },
        )
    }
}

fn generate_enum_fmt_body(ident: &str, variants: &[VariantFormattingData]) -> TokenStream {
    let write_invocations = variants
        .iter()
        .map(|v| generate_variant_write_invocation(ident, v));
    quote! { match &self { #(#write_invocations),* } }
}

fn generate_write_invocation<T: Fn(usize, Option<Ident>) -> TokenStream>(
    ident: &str,
    fields: &Enumerate<Iter<FieldFormattingData>>,
    accessor_generator: T,
) -> TokenStream {
    let field_fmt_strs = collect_field_fmt_strs(fields);
    let field_accessors = fields
        .clone()
        .map(|(idx, field)| generate_accessor(idx, field, &accessor_generator));

    let fmt_str = ident.to_owned() + " {{ " + &field_fmt_strs.join(", ") + " }}";

    quote! { write!(f, #fmt_str, #(#field_accessors),*) }
}

fn generate_variant_write_invocation(
    enum_ident: &str,
    variant: &VariantFormattingData,
) -> TokenStream {
    let variant_name: String = enum_ident.to_owned() + "::" + &variant.ident;
    let variant_path: Path = parse_str(&variant_name).expect("String is not tokens");
    if variant.fields.is_empty() {
        quote! { #variant_path => write!(f, #variant_name) }
    } else {
        let fields = variant.fields.iter().enumerate().map(|(idx, field)| {
            generate_variant_accessor(idx, field.ident.clone().map(|it| format_ident!("{}", it)))
        });
        let write_invocation = generate_write_invocation(
            &variant_name,
            &variant.fields.iter().enumerate(),
            generate_variant_accessor,
        );

        if variant.fields[0].ident.is_some() {
            quote! { #variant_path{#(#fields),*} => #write_invocation }
        } else {
            quote! { #variant_path(#(#fields),*) => #write_invocation }
        }
    }
}

fn generate_accessor<T: Fn(usize, Option<Ident>) -> TokenStream>(
    idx: usize,
    field: &FieldFormattingData,
    quote: &T,
) -> TokenStream {
    if field.sensitive {
        quote! { "<redacted>" }
    } else {
        let ident = field.ident.clone().map(|it| format_ident!("{}", it));
        quote(idx, ident)
    }
}

fn generate_struct_accessor(idx: usize, ident: &Option<Ident>) -> TokenStream {
    let index: Index = idx.into();
    match &ident {
        None => quote! { self.#index },
        Some(ident) => quote! { self.#ident },
    }
}

fn generate_variant_accessor(idx: usize, ident: Option<Ident>) -> TokenStream {
    let id = match ident {
        None => format_ident!("f{}", idx),
        Some(it) => it,
    };
    quote! {#id}
}

fn collect_field_fmt_strs(fields: &Enumerate<Iter<FieldFormattingData>>) -> Vec<String> {
    fields
        .clone()
        .map(|(idx, field)| {
            get_field_name_or_index(idx, field) + ": " + if field.sensitive { "{}" } else { "{:?}" }
        })
        .collect::<Vec<String>>()
}

fn get_field_name_or_index(idx: usize, field: &FieldFormattingData) -> String {
    match &field.ident {
        None => format!("{idx}"),
        Some(ident) => ident.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::FmtBodySource;
    use crate::formatting_data::*;
    use pretty_assertions::assert_eq;
    use quote::quote;

    #[test]
    fn should_generate_fmt_body_for_empty_struct_data() {
        let mut source = MockFormattingDataSource::new();
        source
            .expect_to_formatting_data()
            .returning(|| Ok(FormattingData::StructData(vec![])));

        assert_eq!(
            source
                .generate_fmt_body("TestStruct")
                .expect("Should have succeeded")
                .to_string(),
            quote!(write!(f, "TestStruct")).to_string()
        );
    }

    #[test]
    fn should_generate_fmt_body_for_named_field_struct_data() {
        let mut source = MockFormattingDataSource::new();
        source.expect_to_formatting_data().returning(|| {
            Ok(FormattingData::StructData(vec![
                FieldFormattingData {
                    ident: Some("a".to_owned()),
                    sensitive: false,
                },
                FieldFormattingData {
                    ident: Some("b".to_owned()),
                    sensitive: true,
                },
            ]))
        });

        assert_eq!(
            source
                .generate_fmt_body("TestStruct")
                .expect("Should have succeeded")
                .to_string(),
            quote!(write!(
                f,
                "TestStruct {{ a: {:?}, b: {} }}",
                self.a, "<redacted>"
            ))
            .to_string()
        );
    }

    #[test]
    fn should_generate_fmt_body_for_unnamed_field_struct_data() {
        let mut source = MockFormattingDataSource::new();
        source.expect_to_formatting_data().returning(|| {
            Ok(FormattingData::StructData(vec![
                FieldFormattingData {
                    ident: None,
                    sensitive: false,
                },
                FieldFormattingData {
                    ident: None,
                    sensitive: true,
                },
            ]))
        });

        assert_eq!(
            source
                .generate_fmt_body("TestStruct")
                .expect("Should have succeeded")
                .to_string(),
            quote!(write!(
                f,
                "TestStruct {{ 0: {:?}, 1: {} }}",
                self.0, "<redacted>"
            ))
            .to_string()
        );
    }

    #[test]
    #[rustfmt::skip]
    fn should_generate_fmt_body_for_empty_enum_data() {
        let mut source = MockFormattingDataSource::new();
        source.expect_to_formatting_data().returning(|| {
            Ok(FormattingData::EnumData(vec![VariantFormattingData {
                ident: "A".to_owned(),
                fields: vec![],
            }]))
        });

        assert_eq!(
            source
                .generate_fmt_body("TestEnum")
                .expect("Should have succeeded")
                .to_string(),
            quote!(match &self {
                TestEnum::A => write!(f, "TestEnum::A")
            })
            .to_string()
        );
    }

    #[test]
    fn should_generate_fmt_body_for_named_field_enum_data() {
        let mut source = MockFormattingDataSource::new();
        source.expect_to_formatting_data().returning(|| {
            Ok(FormattingData::EnumData(vec![VariantFormattingData {
                ident: "A".to_owned(),
                fields: vec![
                    FieldFormattingData {
                        ident: Some("a".to_owned()),
                        sensitive: false,
                    },
                    FieldFormattingData {
                        ident: Some("b".to_owned()),
                        sensitive: true,
                    },
                ],
            }]))
        });

        assert_eq!(
            source.generate_fmt_body("TestEnum").expect("Should have succeeded").to_string(),
            quote! {
                match &self {
                    TestEnum::A{a, b} => write!(f, "TestEnum::A {{ a: {:?}, b: {} }}", a, "<redacted>")
                }
            }.to_string()
        );
    }

    #[test]
    fn should_generate_fmt_body_for_unnamed_field_enum_data() {
        let mut source = MockFormattingDataSource::new();
        source.expect_to_formatting_data().returning(|| {
            Ok(FormattingData::EnumData(vec![VariantFormattingData {
                ident: "A".to_owned(),
                fields: vec![
                    FieldFormattingData {
                        ident: None,
                        sensitive: false,
                    },
                    FieldFormattingData {
                        ident: None,
                        sensitive: true,
                    },
                ],
            }]))
        });

        assert_eq!(
            source.generate_fmt_body("TestEnum").expect("Should have succeeded").to_string(),
            quote! {
                match &self {
                    TestEnum::A(f0, f1) => write!(f, "TestEnum::A {{ 0: {:?}, 1: {} }}", f0, "<redacted>")
                }
            }.to_string()
        );
    }
}