KbAssembleMojo.java

/*
 * Copyright © 2026 IKE Network (support@ike.network)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package network.ike.plugin;

import network.ike.knowledge.spi.ArtifactInput;
import network.ike.knowledge.spi.AssembleRequest;
import network.ike.knowledge.spi.AssembleResult;
import network.ike.knowledge.spi.IkeServiceBootstrap;
import network.ike.knowledge.spi.KnowledgeBaseAssembler;
import network.ike.knowledge.spi.ViewSpec;
import org.apache.maven.api.ProducedArtifact;
import org.apache.maven.api.Project;
import org.apache.maven.api.Session;
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.services.ProjectManager;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;

/**
 * Assembles a knowledge base from ordered knowledge artifacts — base data plus change
 * sets into a store, classified by default — optionally exports the classified store
 * as a standalone {@code reasoned-pb} artifact (IKE-Network/ike-issues#933), and
 * optionally installs it into a
 * data-source directory a knowledge browser reads. The full-cycle goal: a starter set
 * becomes an openable, navigable KB in one build (IKE-Network/ike-issues#848, #850).
 *
 * <p>The goal is a thin face over the knowledge-pipeline SPI: it builds a typed
 * {@link AssembleRequest}, and the {@link KnowledgeBaseAssembler} implementation is
 * resolved by ServiceLoader from the <em>project's</em> runtime classpath inside a
 * forked seam — declare {@code network.ike.knowledge:ike-knowledge-provider} at the use
 * site (or rely on the parent POM's default wiring).
 *
 * <p>Inputs are ordered {@code ROLE spec} entries: the role is {@code STORE_SEED}
 * (first position only), {@code PB}, or {@code CHANGESET}; the spec is either a Maven
 * coordinate ({@code groupId:artifactId[:extension[:classifier]]:version}, resolved
 * from the repositories) or a file path — captured verification change sets feed the
 * next iteration as file inputs:
 *
 * <pre>{@code
 * <inputs>
 *     <input>PB dev.ikm.data.tinkar:tinkar-starter-data:zip:reasoned-pb:20251009</input>
 *     <input>CHANGESET ${project.basedir}/../my-changeset/target/my-changeset.zip</input>
 * </inputs>
 * }</pre>
 *
 * @since 235
 */
@Mojo(name = IkeGoal.NAME_KB_ASSEMBLE,
      defaultPhase = "package")
public class KbAssembleMojo implements org.apache.maven.api.plugin.Mojo {

    /** Creates this goal instance. */
    public KbAssembleMojo() {}

    @Inject
    private org.apache.maven.api.plugin.Log log;

    /**
     * Access the Maven logger.
     *
     * @return the logger
     */
    protected org.apache.maven.api.plugin.Log getLog() {
        return log;
    }

    @Inject
    private Session session;

    @Inject
    private Project project;

    /**
     * The ordered knowledge inputs, each {@code ROLE spec} — role
     * {@code STORE_SEED | PB | CHANGESET}, spec a Maven coordinate
     * ({@code g:a[:extension[:classifier]]:v}) or a file path. List order is load
     * order; the base comes first.
     */
    @Parameter
    List<String> inputs = List.of();

    /**
     * The store root to assemble into.
     */
    @Parameter(property = "ike.kbAssemble.storeRoot",
               defaultValue = "${project.build.directory}/kb")
    String storeRoot;

    /**
     * Delete the store root before assembling — the default, so every build assembles
     * the declared knowledge-state from scratch.
     */
    @Parameter(property = "ike.kbAssemble.cleanStart", defaultValue = "true")
    boolean cleanStart;

    /**
     * Run classification as the final assembly step — the default posture; the
     * assembled KB is navigable the moment a browser opens it.
     */
    @Parameter(property = "ike.kbAssemble.classify", defaultValue = "true")
    boolean classify;

    /**
     * The reasoner service's simple class name, when the classpath carries several.
     */
    @Parameter(property = "ike.kbAssemble.reasonerService")
    String reasonerService;

    /**
     * A directory to install the assembled store into — for example a data-source
     * directory a knowledge browser reads ({@code ${user.home}/Solor/<name>}).
     * Replaced wholesale on each install.
     */
    @Parameter(property = "ike.kbAssemble.installDirectory")
    String installDirectory;

    /**
     * Export the classified store as a full standalone reasoned protobuf — the
     * {@code reasoned-pb} classifier form, inferred results baked in, ready to open
     * without a reasoner run (IKE-Network/ike-issues#933). Requires {@link #classify}.
     */
    @Parameter(property = "ike.kbAssemble.reasonedPb", defaultValue = "false")
    boolean reasonedPb;

    /**
     * The reasoned-protobuf export file. When unset and {@link #reasonedPb} is true,
     * defaults to {@code <buildDirectory>/<artifactId>-<version>-reasoned-pb.zip}.
     * Setting this implies {@link #reasonedPb}.
     */
    @Parameter(property = "ike.kbAssemble.reasonedPbFile")
    String reasonedPbFile;

    /**
     * Attach the reasoned-protobuf export to the project under the
     * {@code reasoned-pb} classifier (extension {@code zip}), so it installs and
     * deploys with the module.
     */
    @Parameter(property = "ike.kbAssemble.attachReasonedPb", defaultValue = "true")
    boolean attachReasonedPb;

    /**
     * The view specification's dotted dimension keys (IKE-KNOWLEDGE-VIEW), stating only
     * what differs from the implementation's defaults.
     */
    @Parameter
    Map<String, String> view = Map.of();

    /**
     * The knowledge-service implementation's simple class name, when the classpath
     * carries several.
     */
    @Parameter(property = "ike.kbAssemble.implementation")
    String implementation;

    /**
     * Fork the seam (the default posture). Disable only for debugging.
     */
    @Parameter(property = "ike.kbAssemble.fork", defaultValue = "true")
    boolean fork;

    /**
     * Extra child-JVM arguments for the forked seam (heap, flags). The child inherits
     * {@code --enable-preview} from the Maven JVM automatically.
     */
    @Parameter
    List<String> forkJvmArguments = List.of();

    /**
     * The project's classes/resources directory, included on the seam classpath.
     */
    @Parameter(property = "ike.kbAssemble.classesDirectory",
               defaultValue = "${project.build.outputDirectory}")
    String classesDirectory;

    /**
     * The build directory hosting the seam's request/result files.
     */
    @Parameter(property = "ike.kbAssemble.buildDirectory",
               defaultValue = "${project.build.directory}")
    String buildDirectory;

    /**
     * Skip knowledge-base assembly.
     */
    @Parameter(property = "ike.kbAssemble.skip", defaultValue = "false")
    boolean skip;

    /**
     * Resolves the ordered inputs, builds the typed assembly request, and runs the
     * {@link KnowledgeBaseAssembler} across the forked seam.
     *
     * @throws MojoException if an input cannot be parsed or resolved, the seam fails,
     *                       or the store was not produced
     */
    @Override
    public void execute() {
        if (skip) {
            getLog().info("ike:kb-assemble skipped (ike.kbAssemble.skip=true)");
            return;
        }
        if (inputs.isEmpty()) {
            throw new MojoException("ike:kb-assemble requires at least one <inputs><input> entry"
                    + " (ROLE followed by a Maven coordinate or file path)");
        }

        Optional<Path> reasonedPbTarget = reasonedPbTarget();
        AssembleRequest request = new AssembleRequest(
                Path.of(storeRoot), cleanStart, resolveInputs(), ViewSpec.of(view), classify,
                Optional.ofNullable(reasonerService).filter(s -> !s.isBlank()),
                Optional.ofNullable(installDirectory).filter(s -> !s.isBlank()).map(Path::of),
                reasonedPbTarget);
        Properties wire = request.toProperties();
        if (implementation != null && !implementation.isBlank()) {
            wire.setProperty(IkeServiceBootstrap.IMPLEMENTATION_KEY, implementation);
        }

        Properties resultWire = new KnowledgeServiceRunner(getLog()).run(
                KnowledgeBaseAssembler.class.getName(), wire, seamClasspath(),
                Path.of(buildDirectory, "ike-knowledge"), fork, forkJvmArguments);
        AssembleResult result = AssembleResult.fromProperties(resultWire);

        if (!Files.isDirectory(Path.of(storeRoot))) {
            throw new MojoException("Knowledge-base assembly produced no store at " + storeRoot);
        }
        result.loads().forEach(load -> getLog().info("Loaded " + load.artifact() + " — "
                + load.counts().total() + " entities (" + load.counts().concepts()
                + " concepts, " + load.counts().semantics() + " semantics, "
                + load.counts().patterns() + " patterns, " + load.counts().stamps() + " stamps)"));
        result.classification().ifPresentOrElse(
                summary -> getLog().info("Classified by " + summary.service() + ": "
                        + summary.conceptCount() + " concepts, " + summary.inferredChanges()
                        + " inferred changes, " + summary.navigationChanges()
                        + " navigation changes in " + summary.elapsedMillis() + " ms"),
                () -> getLog().info("Classification skipped by request"));
        result.effectiveViewReport().ifPresent(report ->
                getLog().info("Effective view reported: " + report));
        if (installDirectory != null && !installDirectory.isBlank()) {
            getLog().info("Knowledge base installed: " + installDirectory);
        }
        if (reasonedPbTarget.isPresent()) {
            Path produced = result.reasonedPbFile().orElseThrow(() -> new MojoException(
                    "Reasoned-protobuf export was requested but the assembly reported none"));
            if (!Files.isRegularFile(produced)) {
                throw new MojoException("Reasoned-protobuf export produced no file at " + produced);
            }
            getLog().info("Reasoned knowledge set exported: " + produced.getFileName());
            if (attachReasonedPb) {
                ProducedArtifact artifact = session.createProducedArtifact(
                        project.getGroupId(), project.getArtifactId(), project.getVersion(),
                        "reasoned-pb", "zip", "zip");
                session.getService(ProjectManager.class).attachArtifact(project, artifact, produced);
                getLog().info("Attached reasoned knowledge export " + produced.getFileName()
                        + " (classifier: reasoned-pb, extension: zip)");
            }
        }
    }

    /**
     * The reasoned-protobuf export target: the explicit {@link #reasonedPbFile} when
     * set, the conventional {@code <artifactId>-<version>-reasoned-pb.zip} under the
     * build directory when {@link #reasonedPb} asked for one, empty otherwise.
     *
     * @return the export target, when an export was requested
     */
    private Optional<Path> reasonedPbTarget() {
        if (reasonedPbFile != null && !reasonedPbFile.isBlank()) {
            return Optional.of(Path.of(reasonedPbFile));
        }
        if (reasonedPb) {
            return Optional.of(Path.of(buildDirectory).resolve(
                    project.getArtifactId() + "-" + project.getVersion() + "-reasoned-pb.zip"));
        }
        return Optional.empty();
    }

    private List<ArtifactInput> resolveInputs() {
        List<ArtifactInput> resolved = new ArrayList<>();
        for (String entry : inputs) {
            String stripped = entry.strip();
            int space = stripped.indexOf(' ');
            if (space <= 0) {
                throw new MojoException("Input entry needs \"ROLE spec\": " + entry);
            }
            ArtifactInput.Role role;
            try {
                role = ArtifactInput.Role.valueOf(stripped.substring(0, space));
            } catch (IllegalArgumentException e) {
                throw new MojoException("Unknown input role in \"" + entry
                        + "\" — use STORE_SEED, PB, or CHANGESET", e);
            }
            String spec = stripped.substring(space + 1).strip();
            resolved.add(new ArtifactInput(role, resolveSpec(spec)));
        }
        return resolved;
    }

    /**
     * A spec with two or more colons is a Maven coordinate
     * ({@code g:a[:extension[:classifier]]:v}); anything else is a file path.
     */
    private Path resolveSpec(String spec) {
        if (spec.chars().filter(c -> c == ':').count() >= 2) {
            try {
                return session.resolveArtifact(session.createArtifactCoordinates(spec)).getPath();
            } catch (Exception e) {
                throw new MojoException("Cannot resolve knowledge input " + spec, e);
            }
        }
        Path file = Path.of(spec);
        if (!Files.isRegularFile(file)) {
            throw new MojoException("Knowledge input file does not exist: " + file
                    + " — captured change sets must be present before assembly");
        }
        return file;
    }

    private List<Path> seamClasspath() {
        // Resolution goes through the plugin's serialized entry point — Maven 4 rc-5's
        // resolver cache is not thread-safe under -T (IKE-Network/ike-issues#901).
        List<Path> classpath = new ArrayList<>();
        Path classesDir = Path.of(classesDirectory);
        if (Files.isDirectory(classesDir)) {
            classpath.add(classesDir);
        }
        classpath.addAll(RuntimeClasspathResolver.mainRuntimePaths(session, project));
        return classpath;
    }
}