PromptTemplateValidator.java

package org.egothor.methodatlas.ai;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Validates that a prompt template is structurally sound before it is used.
 *
 * <p>
 * Validation is deliberately limited to what a tool can verify deterministically:
 * </p>
 * <ul>
 *   <li>the template is non-blank;</li>
 *   <li>every {@code {token}} it uses is permitted for its {@link PromptTemplateKind};</li>
 *   <li>every required token for that kind is present;</li>
 *   <li>the template still contains the JSON structural anchor the response parser
 *       relies on (for example {@code "methods"} or {@code "secrets"}).</li>
 * </ul>
 *
 * <p>
 * It does <strong>not</strong>, and cannot, verify that an LLM will understand or
 * follow the instructions — that is the template author's responsibility. The
 * {@code -check-prompts} CLI mode exposes this validator so authors get fast feedback.
 * </p>
 *
 * <p>
 * This class is a stateless, thread-safe utility holder.
 * </p>
 *
 * @since 4.1.0
 */
public final class PromptTemplateValidator {

    /**
     * Matches a placeholder of the form <code>{identifier}</code>. JSON braces such as
     * <code>{ "x": 1 }</code> or <code>{"x":1}</code> do not match because a letter must
     * immediately follow the opening brace.
     */
    private static final Pattern TOKEN = Pattern.compile("\\{([A-Za-z][A-Za-z0-9_]*)\\}");

    private PromptTemplateValidator() {
        // utility class
    }

    /**
     * Returns the placeholder token names used in a template body, in first-seen order.
     *
     * @param template the template body; must not be {@code null}
     * @return an ordered set of token names without braces; never {@code null}
     * @throws NullPointerException if {@code template} is {@code null}
     */
    public static Set<String> tokensIn(String template) {
        Objects.requireNonNull(template, "template");
        Set<String> tokens = new LinkedHashSet<>();
        Matcher matcher = TOKEN.matcher(template);
        while (matcher.find()) {
            tokens.add(matcher.group(1));
        }
        return tokens;
    }

    /**
     * Validates a template and returns the list of problems found.
     *
     * @param kind     the template kind whose token rules apply; must not be {@code null}
     * @param template the template body to validate; must not be {@code null}
     * @return an immutable list of human-readable problems; empty when the template is
     *         valid; never {@code null}
     * @throws NullPointerException if any argument is {@code null}
     */
    public static List<String> validate(PromptTemplateKind kind, String template) {
        Objects.requireNonNull(kind, "kind");
        Objects.requireNonNull(template, "template");

        List<String> problems = new ArrayList<>();
        if (template.isBlank()) {
            problems.add("template is empty");
            return List.copyOf(problems);
        }

        Set<String> used = tokensIn(template);
        Set<String> unknown = new TreeSet<>(used);
        unknown.removeAll(kind.allowedTokens());
        for (String token : unknown) {
            problems.add("unknown placeholder {" + token + "}; allowed: "
                    + new TreeSet<>(kind.allowedTokens()));
        }

        Set<String> missing = new TreeSet<>(kind.requiredTokens());
        missing.removeAll(used);
        for (String token : missing) {
            problems.add("missing required placeholder {" + token + "}");
        }

        if (!template.contains(kind.structuralAnchor())) {
            problems.add("missing JSON structural anchor " + kind.structuralAnchor()
                    + "; the response parser depends on it");
        }
        return List.copyOf(problems);
    }

    /**
     * Validates a template and throws if it is invalid.
     *
     * @param kind     the template kind whose token rules apply; must not be {@code null}
     * @param template the template body to validate; must not be {@code null}
     * @param sourceLabel a short label identifying the template source (for example a
     *                    file name) used in the exception message; must not be {@code null}
     * @throws NullPointerException     if any argument is {@code null}
     * @throws PromptTemplateException  if the template is invalid; the message lists
     *                                  every problem found
     */
    public static void validateOrThrow(PromptTemplateKind kind, String template, String sourceLabel) {
        Objects.requireNonNull(sourceLabel, "sourceLabel");
        List<String> problems = validate(kind, template);
        if (!problems.isEmpty()) {
            throw new PromptTemplateException("Invalid " + kind + " prompt template (" + sourceLabel
                    + "): " + String.join("; ", problems));
        }
    }
}