KnowledgeExportMojo.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 org.apache.maven.api.PathScope;
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.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import org.apache.maven.api.services.DependencyResolver;
import org.apache.maven.api.services.DependencyResolverResult;
import org.apache.maven.api.services.ProjectManager;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * Exports a ledger-form knowledge set as its protobuf change-set artifact: composes the
 * project's {@code KnowledgeSetSource}, replays the session into a fresh ephemeral
 * store, exports the store, and attaches the file as the {@code changeset} classifier —
 * the released form of the set. A starter set is exactly this artifact applied to an
 * empty base.
 *
 * <p>The exporter lives tinkar-side ({@code dev.ikm.tinkar.entity.builder.ChangeSetMain})
 * because this plugin stays tinkar-free (the foundation boundary): the goal builds a
 * classloader over the project's {@code MAIN_RUNTIME} dependency classpath — plus the
 * project's own classes directory — and invokes the entry point reflectively,
 * in-process. Unlike {@code ike:knowledge-bindings}, export replays into a store, so the
 * project must carry the tinkar ephemeral/entity/executor providers at runtime scope and
 * supply their legacy {@code META-INF/services} controller registrations in its own
 * resources (tinkar providers declare services in {@code module-info} only; the goal
 * runs on the classpath).
 *
 * <p>Typical use — a {@code *-changeset} module depending on its {@code *-terms} ledger
 * module plus the providers:
 *
 * <pre>{@code
 * <plugin>
 *     <groupId>network.ike.tooling</groupId>
 *     <artifactId>ike-maven-plugin</artifactId>
 *     <executions>
 *         <execution>
 *             <goals><goal>knowledge-export</goal></goals>
 *         </execution>
 *     </executions>
 * </plugin>
 * }</pre>
 *
 * @since 234
 */
@Mojo(name = IkeGoal.NAME_KNOWLEDGE_EXPORT,
      defaultPhase = "package")
public class KnowledgeExportMojo implements org.apache.maven.api.plugin.Mojo {

    /** The stable tinkar-side entry point — change in lockstep with tinkar-core. */
    static final String CHANGESET_MAIN = "dev.ikm.tinkar.entity.builder.ChangeSetMain";

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

    @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 change-set file to write and attach.
     */
    @Parameter(property = "ike.knowledgeExport.outputFile",
               defaultValue = "${project.build.directory}/${project.artifactId}-${project.version}-changeset.zip")
    String outputFile;

    /**
     * Fully qualified name of the {@code KnowledgeSetSource} implementation to compose.
     * Optional: when absent, exactly one implementation must be discoverable via
     * {@code META-INF/services} on the project's dependency classpath.
     */
    @Parameter(property = "ike.knowledgeExport.sourceClass")
    String sourceClass;

    /**
     * The project's classes/resources directory, included on the export classpath so the
     * module's own {@code META-INF/services} registrations are discoverable.
     */
    @Parameter(property = "ike.knowledgeExport.classesDirectory",
               defaultValue = "${project.build.outputDirectory}")
    String classesDirectory;

    /**
     * Attach the exported file to the project as the {@code changeset} classifier
     * (extension {@code zip}), so it installs and deploys with the module.
     */
    @Parameter(property = "ike.knowledgeExport.attach", defaultValue = "true")
    boolean attach;

    /**
     * Skip change-set export.
     */
    @Parameter(property = "ike.knowledgeExport.skip", defaultValue = "false")
    boolean skip;

    /**
     * Resolves the project's runtime dependency classpath, invokes the tinkar-side
     * exporter in a classloader over it, and attaches the change-set artifact.
     *
     * @throws MojoException if the classpath cannot be resolved, the exporter entry
     *                       point is absent, export fails, or the output file was not
     *                       produced
     */
    @Override
    public void execute() {
        if (skip) {
            getLog().info("ike:knowledge-export skipped (ike.knowledgeExport.skip=true)");
            return;
        }

        DependencyResolverResult resolved = session.getService(DependencyResolver.class)
                .resolve(session, project, PathScope.MAIN_RUNTIME);
        List<URL> classpath = new ArrayList<>();
        try {
            Path classesDir = Path.of(classesDirectory);
            if (Files.isDirectory(classesDir)) {
                classpath.add(classesDir.toUri().toURL());
            }
            for (Path path : resolved.getPaths()) {
                classpath.add(path.toUri().toURL());
            }
        } catch (Exception e) {
            throw new MojoException("Cannot assemble export classpath", e);
        }
        getLog().debug("knowledge-export classpath: " + classpath);

        List<String> args = new ArrayList<>(List.of(outputFile));
        if (sourceClass != null && !sourceClass.isBlank()) {
            args.add(sourceClass);
        }

        ClassLoader originalContext = Thread.currentThread().getContextClassLoader();
        try (URLClassLoader loader = new URLClassLoader("ike-knowledge-export",
                classpath.toArray(new URL[0]), ClassLoader.getPlatformClassLoader())) {
            Thread.currentThread().setContextClassLoader(loader);
            Class<?> mainClass = Class.forName(CHANGESET_MAIN, true, loader);
            Method main = mainClass.getMethod("main", String[].class);
            main.invoke(null, (Object) args.toArray(new String[0]));
        } catch (ClassNotFoundException e) {
            throw new MojoException(CHANGESET_MAIN + " is not on the project's dependency"
                    + " classpath — the ledger dependency chain must include dev.ikm.tinkar:entity"
                    + " (with the knowledge-set builder API)", e);
        } catch (InvocationTargetException e) {
            throw new MojoException("Change-set export failed: "
                    + e.getCause().getMessage(), e.getCause());
        } catch (MojoException e) {
            throw e;
        } catch (Exception e) {
            throw new MojoException("Change-set export failed", e);
        } finally {
            Thread.currentThread().setContextClassLoader(originalContext);
        }

        Path produced = Path.of(outputFile);
        if (!Files.isRegularFile(produced)) {
            throw new MojoException("Change-set export produced no file at " + produced);
        }

        if (attach) {
            ProducedArtifact artifact = session.createProducedArtifact(
                    project.getGroupId(), project.getArtifactId(), project.getVersion(),
                    "changeset", "zip", "zip");
            session.getService(ProjectManager.class).attachArtifact(project, artifact, produced);
            getLog().info("Attached change set " + produced.getFileName()
                    + " (classifier: changeset, extension: zip)");
        } else {
            getLog().info("Change set written (not attached): " + produced);
        }
    }
}