koruma-derive-core 0.7.1

Core of koruma-derive
Documentation
use crate::{
    FieldInfo, KorumaAttr, ParseFieldResult, ValidatorAttr, contains_infer_type,
    expr_as_simple_ident, find_value_field, first_generic_arg, is_option_infer_type,
    option_inner_type, parse_field, substitute_infer_type, type_to_ident, vec_inner_type,
};

fn parse_field_info(field: &syn::Field) -> FieldInfo {
    match parse_field(field, 0) {
        ParseFieldResult::Valid(info) => *info,
        other => panic!("expected ParseFieldResult::Valid, got {other:?}"),
    }
}

#[test]
fn validator_attr_helpers_and_error_paths() {
    let plain: ValidatorAttr = syn::parse_quote!(RangeValidation);
    assert!(!plain.has_args());
    assert!(!plain.uses_type_inference());
    assert!(!plain.has_explicit_type());

    let with_args: ValidatorAttr = syn::parse_quote!(RangeValidation(min = 0, max = 10));
    assert!(with_args.has_args());

    let infer: ValidatorAttr = syn::parse_quote!(GenericValidation::<_>);
    assert!(infer.uses_type_inference());
    assert!(!infer.has_explicit_type());

    let explicit: ValidatorAttr = syn::parse_quote!(GenericValidation::<i32>);
    assert!(!explicit.uses_type_inference());
    assert!(explicit.has_explicit_type());

    let old_syntax: Result<ValidatorAttr, _> = syn::parse_str("GenericValidation<_>");
    assert!(old_syntax.is_err());

    let old_syntax_with_spaces: Result<ValidatorAttr, _> =
        syn::parse_str("GenericValidation < _ >");
    assert!(old_syntax_with_spaces.is_err());

    let too_many_types: Result<ValidatorAttr, _> = syn::parse_str("GenericValidation::<i32, u32>");
    assert!(
        too_many_types
            .err()
            .expect("expected parse error")
            .to_string()
            .contains("exactly one type argument")
    );

    let non_type_generic: Result<ValidatorAttr, _> = syn::parse_str("GenericValidation::<1>");
    assert!(
        non_type_generic
            .err()
            .expect("expected parse error")
            .to_string()
            .contains("expects a type argument")
    );

    let parenthesized_path: Result<ValidatorAttr, _> = syn::parse_str("std::ops::Fn(i32)");
    assert!(parenthesized_path.is_err());

    let direct_parenthesized: Result<ValidatorAttr, _> = syn::parse_str("Fn(i32)");
    assert!(direct_parenthesized.is_err());
}

#[test]
fn koruma_attr_helpers_and_newtype_parsing_paths() {
    let attr: KorumaAttr =
        syn::parse_quote!(RangeValidation(min = 0, max = 10), each(PositiveValidation));
    assert!(attr.has_validators());
    assert!(!attr.is_modifier());

    let skip: KorumaAttr = syn::parse_quote!(skip);
    assert!(!skip.has_validators());
    assert!(skip.is_modifier());

    let nested: KorumaAttr = syn::parse_quote!(nested);
    assert!(!nested.has_validators());
    assert!(nested.is_modifier());

    let newtype_only: KorumaAttr = syn::parse_quote!(newtype);
    assert!(newtype_only.is_newtype);
    assert!(newtype_only.is_modifier());

    let newtype_with_validators: KorumaAttr = syn::parse_quote!(
        newtype,
        each(PositiveValidation),
        RangeValidation(min = 0, max = 1)
    );
    assert!(newtype_with_validators.is_newtype);
    assert!(newtype_with_validators.has_validators());
    assert_eq!(newtype_with_validators.field_validators.len(), 1);
    assert_eq!(newtype_with_validators.element_validators.len(), 1);
}

#[test]
fn field_info_and_parse_field_result_helpers() {
    let field: syn::Field = syn::parse_quote! {
        #[koruma(RangeValidation(min = 0, max = 10), each(PositiveValidation))]
        value: Vec<i32>
    };
    let info = parse_field_info(&field);
    assert!(info.has_validators());
    assert!(info.has_element_validators());
    assert!(!info.is_nested());
    assert!(!info.is_newtype());
    let validator_names: Vec<_> = info.validator_names().map(ToString::to_string).collect();
    assert_eq!(
        validator_names,
        vec!["RangeValidation", "PositiveValidation"]
    );

    let nested_field: syn::Field = syn::parse_quote! {
        #[koruma(nested)]
        inner: Inner
    };
    assert!(parse_field_info(&nested_field).is_nested());

    let newtype_field: syn::Field = syn::parse_quote! {
        #[koruma(newtype)]
        wrapped: Wrapper
    };
    assert!(parse_field_info(&newtype_field).is_newtype());

    let valid_result = parse_field(&field, 0);
    assert!(valid_result.is_valid());
    assert!(valid_result.valid().is_some());

    let skip_field: syn::Field = syn::parse_quote! { plain: i32 };
    let skip_result = parse_field(&skip_field, 0);
    assert!(skip_result.is_skip());
    assert!(skip_result.valid().is_none());

    let error_field: syn::Field = syn::parse_quote! {
        #[koruma(RangeValidation<_>)]
        broken: i32
    };
    let error_result = parse_field(&error_field, 0);
    assert!(error_result.is_error());
    assert!(parse_field(&error_field, 0).error().is_some());
    assert!(parse_field(&error_field, 0).valid().is_none());

    let valid_result_for_error = parse_field(&field, 0);
    assert!(valid_result_for_error.error().is_none());
}

#[test]
fn find_value_field_returns_none_without_marker() {
    let input: syn::ItemStruct = syn::parse_quote! {
        struct Validator {
            actual: i32,
        }
    };
    assert!(find_value_field(&input).is_none());

    let tuple_input: syn::ItemStruct = syn::parse_quote! {
        struct TupleValidator(i32);
    };
    assert!(find_value_field(&tuple_input).is_none());
}

#[test]
fn utility_functions_cover_non_happy_paths() {
    let explicit_tuple: syn::Type = syn::parse_quote!((i32, i32));
    let infer_target: syn::Type = syn::parse_quote!(String);
    let unchanged = substitute_infer_type(&explicit_tuple, &infer_target);
    assert_eq!(quote::quote!(#unchanged).to_string(), "(i32 , i32)");

    let explicit_with_infer: syn::Type = syn::parse_quote!(Vec<_>);
    let substituted = substitute_infer_type(&explicit_with_infer, &infer_target);
    assert_eq!(quote::quote!(#substituted).to_string(), "Vec < String >");

    // Type with lifetime only (no type args) - exercises the for loop without Type match
    let ty_with_lifetime_only: syn::Type = syn::parse_quote!(Borrowed<'a>);
    let substituted_lifetime = substitute_infer_type(&ty_with_lifetime_only, &infer_target);
    assert_eq!(
        quote::quote!(#substituted_lifetime).to_string(),
        "Borrowed < 'a >"
    );

    // Nested path with type args - exercises all branches
    let nested_path: syn::Type = syn::parse_quote!(std::collections::HashMap<_, _>);
    let substituted_nested = substitute_infer_type(&nested_path, &infer_target);
    assert_eq!(
        quote::quote!(#substituted_nested).to_string(),
        "std :: collections :: HashMap < String , String >"
    );

    let const_generic: syn::Type = syn::parse_quote!(ArrayLike<1>);
    assert!(first_generic_arg(&const_generic).is_none());
    assert!(!contains_infer_type(&const_generic));

    let lifetime_generic: syn::Type = syn::parse_quote!(Borrowed<'a>);
    assert!(first_generic_arg(&lifetime_generic).is_none());

    let option_concrete: syn::Type = syn::parse_quote!(Option<String>);
    assert!(!is_option_infer_type(&option_concrete));
    let option_infer: syn::Type = syn::parse_quote!(Option<_>);
    assert!(is_option_infer_type(&option_infer));

    let simple_ident_expr: syn::Expr = syn::parse_quote!(password);
    assert_eq!(
        expr_as_simple_ident(&simple_ident_expr).map(ToString::to_string),
        Some("password".to_string())
    );

    let complex_ident_expr: syn::Expr = syn::parse_quote!(self.value);
    assert!(expr_as_simple_ident(&complex_ident_expr).is_none());

    let tuple_type: syn::Type = syn::parse_quote!((i32, i32));
    assert!(option_inner_type(&tuple_type).is_none());
    assert!(vec_inner_type(&tuple_type).is_none());
    assert!(type_to_ident(&tuple_type).is_none());

    let option_without_args: syn::Type = syn::parse_quote!(Option);
    assert!(option_inner_type(&option_without_args).is_none());

    let option_const: syn::Type = syn::parse_quote!(Option<1>);
    assert!(option_inner_type(&option_const).is_none());

    let vec_without_args: syn::Type = syn::parse_quote!(Vec);
    assert!(vec_inner_type(&vec_without_args).is_none());

    let vec_const: syn::Type = syn::parse_quote!(Vec<1>);
    assert!(vec_inner_type(&vec_const).is_none());

    let named_type: syn::Type = syn::parse_quote!(Age);
    assert_eq!(
        type_to_ident(&named_type).map(|ident| ident.to_string()),
        Some("Age".to_string())
    );
}

#[test]
fn koruma_attr_newtype_parser_handles_trailing_commas() {
    let with_trailing_commas: KorumaAttr =
        syn::parse_str("newtype, each(RangeValidation(min = 0, max = 1), PositiveValidation,), RequiredValidation,")
            .expect("newtype parser should accept commas");
    assert!(with_trailing_commas.is_newtype);
    assert_eq!(with_trailing_commas.field_validators.len(), 1);
    assert_eq!(with_trailing_commas.element_validators.len(), 2);

    let plain_with_each: KorumaAttr = syn::parse_str(
        "each(RangeValidation(min = 0, max = 1), PositiveValidation,), RequiredValidation,",
    )
    .expect("plain parser should accept commas");
    assert!(!plain_with_each.is_newtype);
    assert_eq!(plain_with_each.field_validators.len(), 1);
    assert_eq!(plain_with_each.element_validators.len(), 2);
}

#[test]
fn parser_edge_cases_cover_remaining_parse_lines() {
    // Old syntax branch: no turbofish `::` but an immediate `<...>` segment.
    // With spaces, syn parses path first, then leftover tokens cause error.
    let old_syntax_no_colon: Result<ValidatorAttr, _> = syn::parse_str("RangeValidation < _ >");
    let err = old_syntax_no_colon.err().expect("expected parse error");
    let err_str = err.to_string();
    assert!(
        err_str.contains("turbofish") || err_str.contains("unexpected"),
        "expected turbofish or unexpected token error, got: {err_str}"
    );

    // Without spaces, syn parses `<_>` as generic args, and we detect missing `::`
    let old_syntax_no_space: Result<ValidatorAttr, _> = syn::parse_str("RangeValidation<_>");
    let err2 = old_syntax_no_space.err().expect("expected parse error");
    assert!(
        err2.to_string().contains("turbofish"),
        "expected turbofish error, got: {err2}"
    );

    // Parenthesized path arguments branch.
    let parenthesized_path: Result<ValidatorAttr, _> = syn::parse_str("Fn(i32)");
    assert!(parenthesized_path.is_err());

    // `newtype, each(...), ::Path` exercises comma continuation and non-ident validator path
    // in the newtype parser loop.
    let newtype_with_each_and_path: KorumaAttr =
        syn::parse_str("newtype, each(::demo::ElemValidation), ::demo::FieldValidation")
            .expect("newtype attr with `each` and absolute path should parse");
    assert!(newtype_with_each_and_path.is_newtype);
    assert_eq!(newtype_with_each_and_path.element_validators.len(), 1);
    assert_eq!(newtype_with_each_and_path.field_validators.len(), 1);

    // `newtype` followed by a path without a comma currently falls back to regular parsing.
    let newtype_without_comma_falls_through: KorumaAttr =
        syn::parse_str("newtype::demo::FieldValidation")
            .expect("fallback parser should still parse remaining validator path");
    assert!(!newtype_without_comma_falls_through.is_newtype);
    assert_eq!(
        newtype_without_comma_falls_through.field_validators.len(),
        1
    );

    // Non-ident path in the non-newtype parser loop.
    let absolute_path_only: KorumaAttr =
        syn::parse_str("::demo::FieldValidation").expect("absolute validator path should parse");
    assert!(!absolute_path_only.is_newtype);
    assert_eq!(absolute_path_only.field_validators.len(), 1);

    let newtype_each_trailing_comma: KorumaAttr =
        syn::parse_str("newtype, each(::demo::ElemValidation),")
            .expect("newtype each with trailing comma should parse");
    assert!(newtype_each_trailing_comma.is_newtype);
    assert_eq!(newtype_each_trailing_comma.element_validators.len(), 1);
    assert!(newtype_each_trailing_comma.field_validators.is_empty());
}

#[test]
fn field_info_has_validators_covers_element_only_branch() {
    let field: syn::Field = syn::parse_quote! {
        #[koruma(each(PositiveValidation))]
        values: Vec<i32>
    };
    let info = parse_field_info(&field);
    assert!(info.validation.field_validators.is_empty());
    assert!(!info.validation.element_validators.is_empty());
    assert!(info.has_validators());
}

#[test]
fn utility_functions_cover_remaining_line_paths() {
    let ty_with_lifetime: syn::Type = syn::parse_quote!(Borrowed<'static>);
    assert!(first_generic_arg(&ty_with_lifetime).is_none());
    assert!(!contains_infer_type(&ty_with_lifetime));

    let ty_ref: syn::Type = syn::parse_quote!(&str);
    assert!(!contains_infer_type(&ty_ref));

    let option_concrete: syn::Type = syn::parse_quote!(Option<u32>);
    assert!(!is_option_infer_type(&option_concrete));

    let option_infer: syn::Type = syn::parse_quote!(Option<_>);
    assert!(is_option_infer_type(&option_infer));

    // Option with lifetime arg only - exercises the for loop without Type match
    let option_lifetime: syn::Type = syn::parse_quote!(Option<'static>);
    assert!(!is_option_infer_type(&option_lifetime));

    let ty_with_lifetime_and_infer: syn::Type = syn::parse_quote!(Wrapper<'static, _>);
    let infer_target: syn::Type = syn::parse_quote!(usize);
    let substituted = substitute_infer_type(&ty_with_lifetime_and_infer, &infer_target);
    assert_eq!(
        quote::quote!(#substituted).to_string(),
        "Wrapper < 'static , usize >"
    );
}

#[cfg(feature = "internal-showcase")]
#[test]
fn showcase_attr_errors_are_reported() {
    use crate::{ShowcaseAttr, find_showcase_attr};

    let unknown: Result<ShowcaseAttr, _> = syn::parse_str(
        r#"name = "n", description = "d", create = |input: &str| input, nope = "x""#,
    );
    assert!(
        unknown
            .err()
            .expect("expected parse error")
            .to_string()
            .contains("unknown showcase attribute")
    );

    let missing_description: Result<ShowcaseAttr, _> =
        syn::parse_str(r#"name = "n", create = |input: &str| input"#);
    assert!(
        missing_description
            .err()
            .expect("expected parse error")
            .to_string()
            .contains("showcase requires `description` attribute")
    );

    let input: syn::ItemStruct = syn::parse_quote! {
        #[showcase(name = "N", description = "D", create = |input: &str| input)]
        struct Demo;
    };
    assert!(find_showcase_attr(&input).is_some());
}