clap_reverse_impl 0.1.1

Internal subcrate for `clap_reverse` crate
Documentation
//! Field-level attribute parsing and validation.
//!
//! This module defines [`FieldAttributes`], which stores metadata parsed from `#[clap_reverse(...)]` attributes applied to struct fields.
//! It handles validation of supported arguments, detection of `Option<T>` fields,
//! and generation of error messages for invalid attributes.

use quote::ToTokens;
use crate::move_it_somewhere::find_reverse_clap_attribute;


/// Parsed attributes for a single struct field.
///
/// Each instance corresponds to one field in the user struct.  
/// Attributes define how the field maps to a command-line argument.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct FieldAttributes {
    /// Field-level prefix. Overrides struct-level `prefix` if present.
    pub prefix: Option<String>,
    /// Original Rust field name.
    pub field_name: String,
    /// Argument name used in the command. If not present uses `field_name`.
    pub name: Option<String>,
    /// Field-level delimiter. Overrides struct-level `delimiter` if present.
    pub delimiter: Option<String>,
    /// Whether the field type is `Option<T>`.
    pub is_option_ty: bool,
    /// Marks this field as the binary source for the command.
    pub binary: bool,
    /// If true, prints only the value and omits `{prefix}{name}{delimiter}`.
    pub transparent: bool,
    /// If true, prints `{prefix}{name}{delimiter}` omits `{value}`.
    pub flag: bool,
}


impl FieldAttributes {
    /// Documentation for all supported field-level attributes.
    pub const ARGUMENTS: [(&'static str, &'static str); 6] = [
        ("prefix", "sets up a prefix for argument (default: struct level prefix), example: `prefix=\"-\"` -{name} {value}"),
        ("name", "sets up a name for argument (default: field name), example: `name=\"test_argument\"` -test_argument {value}"),
        ("delimiter", "sets up a delimiter for argument (default: struct level delimiter), example: `delimiter=\"=\"` = --{name}={value}"),
        ("binary", "makes this field path for command binary"),
        ("transparent", "makes this field omit it's name, example (defalut: false): '{value}'"),
        ("flag", "makes this field omit it's value, example (default: false): '--{name}'"),
    ];

    /// Parse field-level attributes from a [`syn::Field`].
    pub fn parse(input: &syn::Field) -> syn::Result<Self> {
        let mut this = Self::default();

        find_reverse_clap_attribute(&input.attrs)
            .map(|attribute| attribute.parse_nested_meta(|meta| this.parse_meta(input, meta)))
            .transpose()?;

        this.field_name = input.ident.as_ref().map(|ident| ident.into_token_stream().to_string()).unwrap();
        this.is_option_ty = Self::is_option(input);

        Ok(this)
    }

    /// Parse a single attribute meta item and update `self`.
    fn parse_meta(&mut self, input: &syn::Field, meta: syn::meta::ParseNestedMeta<'_>) -> syn::Result<()> {
        if meta.path.is_ident("prefix") {
            self.prefix = Some(meta.value()?.parse::<syn::LitStr>()?.value());
            Ok(())
        }
        else if meta.path.is_ident("name") {
            self.name = Some(meta.value()?.parse::<syn::LitStr>()?.value());
            Ok(())
        }
        else if meta.path.is_ident("delimiter") {
            self.delimiter = Some(meta.value()?.parse::<syn::LitStr>()?.value());
            Ok(())
        }
        else if meta.path.is_ident("binary") {
            self.binary = true;
            Ok(())
        }
        else if meta.path.is_ident("transparent") {
            self.transparent = true;
            Ok(())
        }
        else if meta.path.is_ident("flag") {
            if let syn::Type::Path(type_path) = &input.ty && type_path.path.is_ident("bool") {
                self.flag = true;
                Ok(())
            } else {
                Err(meta.error("flag argument can be used only on bool fields"))
            }
        }
        else {
            Err(meta.error(Self::unknown_argument_error(meta.path.to_token_stream())))
        }
    }

    /// Returns true if the field type is `Option<T>`.
    fn is_option(field: &syn::Field) -> bool {
        if let syn::Type::Path(type_path) = &field.ty
            && type_path.qself.is_none()
            && type_path.path.segments.len() == 1
        {
            let segment = &type_path.path.segments[0];
            return segment.ident == "Option";
        }

        false
    }

    /// Format an error message for an unknown attribute.
    ///
    /// Lists all supported field-level attributes with usage hints.
    fn unknown_argument_error(argument: impl std::fmt::Display) -> String {
        use std::fmt::Write;

        let mut error_message = format!("Unknown argument: `{argument}`. Available arguments:\n");

        Self::ARGUMENTS.iter().for_each(|(arg, help)| { let _ = writeln!(&mut error_message, "\t- {arg} - {help}"); });

        error_message
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use syn::{parse_quote, Field};

    fn parse_field(input: Field) -> FieldAttributes {
        FieldAttributes::parse(&input).expect("failed to parse field")
    }

    #[test]
    fn test_default_name_and_no_attrs() {
        let field: Field = parse_quote! { text: String };

        let attrs = parse_field(field);

        assert_eq!(attrs.field_name, "text");
        assert_eq!(attrs.name, None);
        assert!(!attrs.binary);
        assert!(!attrs.transparent);
        assert!(!attrs.flag);
        assert!(!attrs.is_option_ty);
        assert!(attrs.prefix.is_none());
        assert!(attrs.delimiter.is_none());
    }

    #[test]
    fn test_prefix_attribute() {
        let field: Field = parse_quote! {
            #[clap_reverse(prefix = "-")]
            arg: String
        };

        let attrs = parse_field(field);

        assert_eq!(attrs.prefix.as_deref(), Some("-"));
    }

    #[test]
    fn test_name_attribute() {
        let field: Field = parse_quote! {
            #[clap_reverse(name = "renamed")]
            arg: String
        };

        let attrs = parse_field(field);

        assert_eq!(attrs.name, Some("renamed".to_string()));
    }

    #[test]
    fn test_delimiter_attribute() {
        let field: Field = parse_quote! {
            #[clap_reverse(delimiter = "=")]
            arg: String
        };

        let attrs = parse_field(field);

        assert_eq!(attrs.delimiter.as_deref(), Some("="));
    }

    #[test]
    fn test_binary_attribute() {
        let field: Field = parse_quote! {
            #[clap_reverse(binary)]
            path: String
        };

        let attrs = parse_field(field);

        assert!(attrs.binary);
    }

    #[test]
    fn test_transparent_attribute() {
        let field: Field = parse_quote! {
            #[clap_reverse(transparent)]
            value: String
        };

        let attrs = parse_field(field);

        assert!(attrs.transparent);
    }

    #[test]
    fn test_flag_attribute_on_bool() {
        let field: Field = parse_quote! {
            #[clap_reverse(flag)]
            verbose: bool
        };

        let attrs = parse_field(field);

        assert!(attrs.flag);
    }

    #[test]
    fn test_flag_attribute_on_non_bool_errors() {
        let field: Field = parse_quote! {
            #[clap_reverse(flag)]
            count: u32
        };

        let result = FieldAttributes::parse(&field);

        assert!(result.is_err());

        let err = result.unwrap_err().to_string();

        assert!(err.contains("flag argument can be used only on bool fields"));
    }

    #[test]
    fn test_is_option_type() {
        let field: Field = parse_quote! { opt: Option<String> };

        let attrs = parse_field(field);

        assert!(attrs.is_option_ty);
    }

    #[test]
    fn test_unknown_attribute_errors() {
        let field: Field = parse_quote! {
            #[clap_reverse(unknown_attr)]
            data: String
        };

        let result = FieldAttributes::parse(&field);

        assert!(result.is_err());

        let err = result.unwrap_err().to_string();

        assert!(err.contains("Unknown argument"));
        assert!(err.contains("Available arguments:"));
        assert!(err.contains("prefix"));
        assert!(err.contains("flag"));
    }
}