CliArgs.java

package org.egothor.methodatlas;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import org.egothor.methodatlas.ai.AiOptions;
import org.egothor.methodatlas.ai.AiProvider;

/**
 * Parses command-line arguments into a {@link CliConfig}.
 *
 * <p>
 * This class centralises all argument-parsing logic for the MethodAtlas
 * application. It is intentionally separated from {@link MethodAtlasApp} to
 * keep each class focused and below the project's cyclomatic-complexity
 * threshold.
 * </p>
 *
 * <p>
 * When a {@code -config <file>} argument is present it is processed first
 * (via a pre-scan) so that the YAML file provides default values before
 * individual command-line flags are evaluated. Command-line flags always take
 * precedence over values from the YAML configuration file.
 * </p>
 *
 * <p>
 * This class is a non-instantiable utility holder.
 * </p>
 *
 * @see CliConfig
 * @see MethodAtlasApp
 */
final class CliArgs {

    private static final String DEFAULT_FILE_SUFFIX = "Test.java";
    private static final String FLAG_CONFIG = "-config";

    /**
     * Prevents instantiation of this utility class.
     */
    private CliArgs() {
    }

    /**
     * Parses command-line arguments into a structured configuration object.
     *
     * <p>
     * If a {@code -config <file>} argument is present it is loaded first and
     * its values seed the initial configuration. Subsequent command-line flags
     * override those defaults.
     * </p>
     *
     * @param args raw command-line arguments
     * @return parsed command-line configuration
     * @throws IllegalArgumentException if an option value is missing, malformed,
     *                                  or unsupported, or if the config file
     *                                  cannot be read
     */
    @SuppressWarnings({"PMD.AvoidReassigningLoopVariables", "PMD.CyclomaticComplexity", "PMD.NPathComplexity"})
    /* default */ static CliConfig parse(String... args) {
        // Pre-scan for -config to load YAML defaults before processing other flags.
        YamlConfig.YamlConfigFile yamlConfig = loadYamlConfigFromArgs(args);

        // Seed initial values from YAML (command-line flags will override these).
        OutputMode outputMode = resolveOutputModeFromYaml(yamlConfig);
        boolean emitMetadata = yamlConfig != null && yamlConfig.emitMetadata;
        List<String> fileSuffixes = yamlConfig != null && yamlConfig.fileSuffixes != null
                ? new ArrayList<>(yamlConfig.fileSuffixes) : new ArrayList<>();
        Set<String> testAnnotations = yamlConfig != null && yamlConfig.testAnnotations != null
                ? new LinkedHashSet<>(yamlConfig.testAnnotations) : new LinkedHashSet<>();
        AiOptions.Builder aiBuilder = AiOptions.builder();
        if (yamlConfig != null && yamlConfig.ai != null) {
            applyYamlAiConfig(aiBuilder, yamlConfig.ai);
        }

        List<Path> paths = new ArrayList<>();
        String manualWorkDir = null;
        String manualResponseDir = null;
        boolean manualIsConsume = false;
        boolean applyTags = false;
        boolean contentHash = yamlConfig != null && yamlConfig.contentHash;
        // Tracks whether the first CLI -file-suffix has been seen; when it is,
        // subsequent -file-suffix values are appended rather than replacing defaults.
        boolean cliFileSuffixSet = false;

        for (int i = 0; i < args.length; i++) {
            String arg = args[i];
            if (arg.startsWith("-ai")) {
                i = applyAiArg(arg, args, i, aiBuilder);
                continue;
            }
            switch (arg) {
                case "-plain" -> outputMode = OutputMode.PLAIN;
                case "-sarif" -> outputMode = OutputMode.SARIF;
                case "-apply-tags" -> applyTags = true;
                case "-content-hash" -> contentHash = true;
                case FLAG_CONFIG -> i++; // value already consumed in pre-scan; skip here
                case "-file-suffix" -> {
                    if (!cliFileSuffixSet) {
                        // First CLI -file-suffix replaces YAML defaults
                        fileSuffixes.clear();
                        cliFileSuffixSet = true;
                    }
                    fileSuffixes.add(nextArg(args, ++i, arg));
                }
                case "-test-annotation" -> testAnnotations.add(nextArg(args, ++i, arg));
                case "-emit-metadata" -> emitMetadata = true;
                case "-manual-prepare" -> {
                    manualWorkDir = nextArg(args, ++i, arg);
                    manualResponseDir = nextArg(args, ++i, arg);
                    manualIsConsume = false;
                }
                case "-manual-consume" -> {
                    manualWorkDir = nextArg(args, ++i, arg);
                    manualResponseDir = nextArg(args, ++i, arg);
                    manualIsConsume = true;
                }
                default -> {
                    if (arg.startsWith("-")) {
                        throw new IllegalArgumentException("Unknown argument: " + arg);
                    }
                    paths.add(Paths.get(arg));
                }
            }
        }

        ManualMode manualMode = null;
        if (manualWorkDir != null) {
            Path workDir = Paths.get(manualWorkDir);
            Path responseDir = Paths.get(manualResponseDir);
            manualMode = manualIsConsume
                    ? new ManualMode.Consume(workDir, responseDir)
                    : new ManualMode.Prepare(workDir, responseDir);
        }

        List<String> resolvedSuffixes = fileSuffixes.isEmpty() ? List.of(DEFAULT_FILE_SUFFIX) : fileSuffixes;
        Set<String> resolvedAnnotations = testAnnotations.isEmpty()
                ? AnnotationInspector.DEFAULT_TEST_ANNOTATIONS : testAnnotations;
        return new CliConfig(outputMode, aiBuilder.build(), paths, resolvedSuffixes, resolvedAnnotations,
                emitMetadata, manualMode, applyTags, contentHash);
    }

    // -------------------------------------------------------------------------
    // YAML config helpers
    // -------------------------------------------------------------------------

    /**
     * Pre-scans {@code args} for a {@code -config <file>} argument and loads the
     * YAML file if found.
     *
     * @param args raw command-line arguments
     * @return parsed YAML config, or {@code null} when no {@code -config} flag is
     *         present
     * @throws IllegalArgumentException if the config file cannot be read
     */
    private static YamlConfig.YamlConfigFile loadYamlConfigFromArgs(String... args) {
        for (int i = 0; i < args.length - 1; i++) {
            if (FLAG_CONFIG.equals(args[i])) {
                Path configPath = Paths.get(args[i + 1]);
                try {
                    return YamlConfig.load(configPath);
                } catch (IOException e) {
                    throw new IllegalArgumentException("Cannot load config file: " + configPath, e);
                }
            }
        }
        return null;
    }

    /**
     * Derives the initial {@link OutputMode} from a loaded YAML config.
     *
     * @param yamlConfig YAML config, or {@code null}
     * @return resolved output mode; defaults to {@link OutputMode#CSV}
     */
    private static OutputMode resolveOutputModeFromYaml(YamlConfig.YamlConfigFile yamlConfig) {
        if (yamlConfig == null || yamlConfig.outputMode == null) {
            return OutputMode.CSV;
        }
        return switch (yamlConfig.outputMode.toLowerCase(Locale.ROOT)) {
            case "plain" -> OutputMode.PLAIN;
            case "sarif" -> OutputMode.SARIF;
            default -> OutputMode.CSV;
        };
    }

    /**
     * Seeds the AI options builder from YAML configuration values.
     *
     * @param builder   AI options builder to update
     * @param aiConfig  AI section of the YAML config; never {@code null}
     */
    @SuppressWarnings("PMD.NPathComplexity")
    private static void applyYamlAiConfig(AiOptions.Builder builder, YamlConfig.YamlAiConfig aiConfig) {
        if (Boolean.TRUE.equals(aiConfig.enabled)) {
            builder.enabled(true);
        }
        if (aiConfig.provider != null) {
            builder.provider(AiProvider.valueOf(aiConfig.provider.toUpperCase(Locale.ROOT)));
        }
        if (aiConfig.model != null) {
            builder.modelName(aiConfig.model);
        }
        if (aiConfig.baseUrl != null) {
            builder.baseUrl(aiConfig.baseUrl);
        }
        if (aiConfig.apiKey != null) {
            builder.apiKey(aiConfig.apiKey);
        }
        if (aiConfig.apiKeyEnv != null) {
            builder.apiKeyEnv(aiConfig.apiKeyEnv);
        }
        if (aiConfig.taxonomyFile != null) {
            builder.taxonomyFile(Paths.get(aiConfig.taxonomyFile));
        }
        if (aiConfig.taxonomyMode != null) {
            builder.taxonomyMode(
                    AiOptions.TaxonomyMode.valueOf(aiConfig.taxonomyMode.toUpperCase(Locale.ROOT)));
        }
        if (aiConfig.maxClassChars != null) {
            builder.maxClassChars(aiConfig.maxClassChars);
        }
        if (aiConfig.timeoutSec != null) {
            builder.timeout(Duration.ofSeconds(aiConfig.timeoutSec));
        }
        if (aiConfig.maxRetries != null) {
            builder.maxRetries(aiConfig.maxRetries);
        }
        if (Boolean.TRUE.equals(aiConfig.confidence)) {
            builder.confidence(true);
        }
    }

    // -------------------------------------------------------------------------
    // AI argument helper
    // -------------------------------------------------------------------------

    /**
     * Applies a single AI-related command-line argument to the builder.
     *
     * <p>
     * Handles all {@code -ai*} flags. Returns the updated argument index so
     * the caller's loop counter stays consistent when the flag consumes an
     * additional value token.
     * </p>
     *
     * @param arg     the flag token being processed
     * @param args    full argument array
     * @param i       current position in {@code args}
     * @param builder AI options builder to update
     * @return updated value of {@code i} after consuming any argument value
     * @throws IllegalArgumentException if a required value token is missing or
     *                                  the flag is unrecognised
     */
    private static int applyAiArg(String arg, String[] args, int i, AiOptions.Builder builder) {
        int idx = i;
        switch (arg) {
            case "-ai" -> builder.enabled(true);
            case "-ai-confidence" -> builder.confidence(true);
            case "-ai-provider" ->
                builder.provider(AiProvider.valueOf(nextArg(args, ++idx, arg).toUpperCase(Locale.ROOT)));
            case "-ai-model" -> builder.modelName(nextArg(args, ++idx, arg));
            case "-ai-base-url" -> builder.baseUrl(nextArg(args, ++idx, arg));
            case "-ai-api-key" -> builder.apiKey(nextArg(args, ++idx, arg));
            case "-ai-api-key-env" -> builder.apiKeyEnv(nextArg(args, ++idx, arg));
            case "-ai-taxonomy" -> builder.taxonomyFile(Paths.get(nextArg(args, ++idx, arg)));
            case "-ai-taxonomy-mode" ->
                builder.taxonomyMode(
                        AiOptions.TaxonomyMode.valueOf(nextArg(args, ++idx, arg).toUpperCase(Locale.ROOT)));
            case "-ai-max-class-chars" -> builder.maxClassChars(Integer.parseInt(nextArg(args, ++idx, arg)));
            case "-ai-timeout-sec" ->
                builder.timeout(Duration.ofSeconds(Long.parseLong(nextArg(args, ++idx, arg))));
            case "-ai-max-retries" -> builder.maxRetries(Integer.parseInt(nextArg(args, ++idx, arg)));
            default -> throw new IllegalArgumentException("Unknown AI argument: " + arg);
        }
        return idx;
    }

    /**
     * Returns the argument value following an option token.
     *
     * @param args   full command-line argument array
     * @param index  index of the expected option value
     * @param option option whose value is being retrieved
     * @return argument value at {@code index}
     * @throws IllegalArgumentException if {@code index} is outside the bounds of
     *                                  {@code args}
     */
    private static String nextArg(String[] args, int index, String option) {
        if (index >= args.length) {
            throw new IllegalArgumentException("Missing value for " + option);
        }
        return args[index];
    }
}