Shell.java

/*
 * MIT License
 *
 * Copyright (c) 2023-2024 Eugene Terekhov
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
package ru.ewc.decita.manual;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import lombok.SneakyThrows;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.ParsedLine;
import org.jline.reader.impl.DefaultParser;
import org.jline.reader.impl.completer.StringsCompleter;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import ru.ewc.decita.DecitaException;

/**
 * I am the shell for manual library testing. My main responsibility is to hide Java complexities
 * behind a simple interface.
 *
 * @since 0.2.2
 */
public final class Shell {
    /**
     * Set of predefined autocompletion options, contains all available commands.
     */
    public static final Set<String> COMMANDS = Set.of("tables", "decide", "state");

    /**
     * Object holding current terminal.
     */
    private final Terminal terminal;

    /**
     * Object that reads the user's input.
     */
    private LineReader reader;

    /**
     * Object that can write anything to console.
     */
    private final PrintWriter writer;

    /**
     * An instance of a manual computation.
     */
    private ManualComputation computation;

    /**
     * Ctor.
     *
     * @throws IOException If the terminal could not be created.
     */
    private Shell() throws IOException {
        this.terminal = TerminalBuilder.terminal();
        this.writer = this.terminal.writer();
        this.reader = this.buildReader();
    }

    /**
     * The method to run a simplified manual testing utility.
     *
     * @param args Collection of String arguments to initialize Shell.
     * @throws IOException If the command line terminal could not be created.
     */
    @SuppressWarnings("PMD.ProhibitPublicStaticMethods")
    public static void main(final String[] args) throws IOException {
        new Shell().commandsCycle();
    }

    /**
     * Creates and runs the infinite cycle of handling user input.
     */
    private void commandsCycle() {
        String line;
        while (true) {
            line = this.reader.readLine("Decita manual > ").trim();
            if ("quit".equalsIgnoreCase(line) || "exit".equalsIgnoreCase(line)) {
                break;
            }
            try {
                this.performCommand(this.reader.getParser().parse(line, 0));
            } catch (final DecitaException exception) {
                this.writer.printf("An error encountered: %s%n", exception.getMessage());
            }
        }
    }

    /**
     * The method to run a single user's command.
     *
     * @param parsed Structure holding the parsed user input.
     */
    private void performCommand(final ParsedLine parsed) {
        final String command = parsed.words().get(0);
        final String param = extractParameterFrom(parsed);
        switch (command) {
            case "tables":
                if (param == null) {
                    this.writer.println("Please give me a path to tables folder...");
                } else {
                    this.pointToSources(param);
                }
                break;
            case "state":
                if (param == null) {
                    this.writer.println("Please give me a path to current state file...");
                } else {
                    this.loadState(param);
                }
                break;
            case "decide":
                if (param == null) {
                    this.writer.println("Please give me a table name...");
                } else {
                    this.decideFor(param);
                }
                break;
            default:
                break;
        }
    }

    /**
     * Store a path to decision tables folder for following computations.
     *
     * @param path The path to decision tables.
     */
    private void pointToSources(final String path) {
        if (this.computation == null) {
            this.computation = new ManualComputation();
        }
        this.computation = this.computation.tablePath(path);
        this.reader = this.buildReader();
        this.writer.printf("Let's import tables from %s%n", path);
    }

    /**
     * Computes a new completion set, including all the loaded table names.
     *
     * @return An instance of {@link StringsCompleter} with all the available commands and tables
     */
    private StringsCompleter commandsAndTableNames() {
        final Set<String> result;
        if (this.computation == null) {
            result = new HashSet<>();
        } else {
            result = new HashSet<>(this.computation.tableNames());
        }
        result.addAll(Shell.COMMANDS);
        return new StringsCompleter(result);
    }

    /**
     * Loads the computational state from a user-specified file.
     *
     * @param path Path to the yaml file, containing desired state.
     */
    @SneakyThrows
    private void loadState(final String path) {
        if (this.computation == null) {
            this.computation = new ManualComputation();
        }
        this.computation = this.computation.statePath(path);
        this.writer.printf("Let's use state from %s%n", path);
    }

    /**
     * Method that resolves the decision table in a specific Computation context.
     *
     * @param table The name of the table to resolve.
     */
    @SneakyThrows
    private void decideFor(final String table) {
        if (this.computation == null) {
            this.writer.println("Please set me up using 'tables' and 'state' commands...");
        } else {
            this.writer.printf("%n--== %s ==--%n", table.toUpperCase(Locale.getDefault()));
            this.computation.decideFor(table).forEach(
                (key, value) -> this.writer.printf("%s : %s%n", key, value)
            );
            this.writer.println();
        }
    }

    /**
     * Extracts a single parameter for the user command.
     *
     * @param parsed The structure holding the parsed user command.
     * @return A single String parameter entered by user.
     */
    private static String extractParameterFrom(final ParsedLine parsed) {
        final String param;
        if (parsed.words().size() > 1) {
            param = parsed.words().get(1).replace("\\", "\\\\");
        } else {
            param = null;
        }
        return param;
    }

    /**
     * Builds a terminal with escaping line parser.
     *
     * @return An instance of a preconfigured line parser.
     */
    private LineReader buildReader() {
        return LineReaderBuilder
            .builder()
            .terminal(this.terminal)
            .completer(this.commandsAndTableNames())
            .parser(new DefaultParser().escapeChars(new char['\\']))
            .build();
    }

}