Stringify.java

package net.hostsharing.hsadminng.repr;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Value;
import net.hostsharing.hsadminng.errors.DisplayAs;

import jakarta.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;

import static java.util.Optional.ofNullable;

public final class Stringify<B> {

    private final String name;
    private Function<? extends B, ?> idProp;
    private final List<Property<B, ?>> props = new ArrayList<>();
    private String separator = ", ";
    private Boolean quotedValues = null;

    public static <B> Stringify<B> stringify(final Class<B> clazz, final String name) {
        return new Stringify<>(clazz, name);
    }

    public static <B> Stringify<B> stringify(final Class<B> clazz) {
        return new Stringify<>(clazz, null);
    }

    private Stringify(final Class<B> clazz, final String name) {
        if (name != null) {
            this.name = name;
        } else {
            final var displayName = clazz.getAnnotation(DisplayAs.class);
            if (displayName != null) {
                this.name = displayName.value();
            } else {
                this.name = clazz.getSimpleName();
            }
        }
    }

    public <V> Stringify<B> withIdProp(final Function<? extends B, V> getter) {
        idProp = getter;
        return this;
    }

    public <V> Stringify<B> withProp(final String propName, final Function<B, V> getter) {
        props.add(new Property<>(propName, getter));
        return this;
    }

    public <V> Stringify<B> withProp(final Function<B, V> getter) {
        props.add(new Property<>(null, getter));
        return this;
    }

    public <V> Stringify<B> withProp(final Function<B, V> getter, final Function<V, ?> mapper) {
        props.add(new Property<>(null, getter, mapper));
        return this;
    }

    public String apply(@NotNull B object) {
        final var propValues = props.stream()
                .map(prop -> new PropertyValue<>(object, prop))
                .filter(PropertyValue::notNullAndNotEmpty)
                .map(propVal -> propName(propVal, "=") + optionallyQuoted(propVal))
                .collect(Collectors.joining(separator));
        return idProp != null
            ? name + "(" + idProp.apply(cast(object)) + ": " + propValues + ")"
            : name + "(" + propValues + ")";
    }

    public Stringify<B> withSeparator(final String separator) {
        this.separator = separator;
        return this;
    }

    private <V> String propName(final PropertyValue<B, V> propVal, final String delimiter) {
        return ofNullable(propVal.prop.name).map(v -> v + delimiter).orElse("");
    }

    private <B, V> String optionallyQuoted(final PropertyValue<B, V> propVal) {
        if (quotedValues == null)
            return quotableValueType(propVal.getValue())
                    ? ("'" + propVal.stringValue + "'")
                    : propVal.stringValue;
        return quotedValues
                ? ("'" + propVal.stringValue + "'")
                : propVal.stringValue;
    }

    private <V> boolean quotableValueType(final V rawValue) {
        return !(rawValue instanceof Enum) &&
                !(rawValue instanceof Symbol) &&
                !(rawValue instanceof Number) &&
                !(rawValue instanceof Boolean);
    }

    /**
     *  Specifies whether the values should be quoted (true) or not (false).
     *
     *  If not specified, Enum, Symbol, Number and Boolean values are not quoted;
     *  other value types are quoted.
     *
     * @param quotedValues
     * @return
     */
    public Stringify<B> quotedValues(final boolean quotedValues) {
        this.quotedValues = quotedValues;
        return this;
    }

    private <T> T cast(final Object object) {
        //noinspection unchecked
        return (T)object;
    }

    @Value
    @AllArgsConstructor
    private class Property<B, V> {
        String name;
        Function<B, V> getter;
        Function<V, ?> mapper; // TODO.impl: better generics?

        Property(String name, Function<B, V> getter) {
            this(name, getter, v -> v);
        }

        Object getValue(final B object) {
            return ofNullable(getter.apply(object))
                .map(mapper)
                .orElse(null);
        }
    }

    @Getter
    private class PropertyValue<B, V> {
        private Property<B, V> prop;
        private V value;
        private String stringValue;

        @SuppressWarnings("unchecked")
        PropertyValue(final B object, final Property<B, ?> prop) {
            this.prop = (Property<B, V>) prop;
            this.value = (V) this.prop.getValue(object);
            this.stringValue = this.value instanceof Stringifyable s
                    ? s.toShortString()
                    : Objects.toString(this.value);
        }

        boolean notNullAndNotEmpty() {
            return value != null &&
                    (!(value instanceof Collection<?> c) || !c.isEmpty()) &&
                    (!(value instanceof Map<?,?> m) || !m.isEmpty()) &&
                    (!(value instanceof String s) || !s.isEmpty());
        }
    }
}