001/*
002 * JDrupes Builder
003 * Copyright (C) 2025, 2026 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.builder.startup;
020
021import com.google.common.flogger.FluentLogger;
022import eu.maveniverse.maven.mima.context.Context;
023import eu.maveniverse.maven.mima.context.ContextOverrides;
024import eu.maveniverse.maven.mima.context.Runtime;
025import eu.maveniverse.maven.mima.context.Runtimes;
026import java.net.MalformedURLException;
027import java.net.URL;
028import java.net.URLClassLoader;
029import java.nio.file.Path;
030import java.util.ArrayList;
031import java.util.Arrays;
032import java.util.List;
033import java.util.Properties;
034import java.util.stream.Collectors;
035import java.util.stream.Stream;
036import org.apache.commons.cli.CommandLine;
037import org.apache.commons.cli.DefaultParser;
038import org.apache.commons.cli.ParseException;
039import org.eclipse.aether.artifact.DefaultArtifact;
040import org.eclipse.aether.collection.CollectRequest;
041import org.eclipse.aether.graph.Dependency;
042import org.eclipse.aether.graph.DependencyNode;
043import org.eclipse.aether.resolution.DependencyRequest;
044import org.eclipse.aether.resolution.DependencyResolutionException;
045import org.eclipse.aether.util.graph.visitor.PreorderNodeListGenerator;
046import org.jdrupes.builder.api.BuildException;
047import org.jdrupes.builder.api.ConfigurationException;
048import org.jdrupes.builder.api.FaultAware;
049import org.jdrupes.builder.api.Launcher;
050import org.jdrupes.builder.api.Masked;
051import org.jdrupes.builder.api.Project;
052import org.jdrupes.builder.api.ResourceFactory;
053import org.jdrupes.builder.api.RootProject;
054import org.jdrupes.builder.api.UnavailableException;
055import org.jdrupes.builder.core.AbstractRootProject;
056import org.jdrupes.builder.java.JarFile;
057import static org.jdrupes.builder.java.JavaTypes.*;
058
059/// An implementation of a [Launcher] that launches the build configuration.
060/// It expects that the JDrupes Builder project has already been compiled
061/// and its classes are available on the classpath.
062///
063public class BuildProjectLauncher extends AbstractLauncher {
064
065    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
066    /// The JDrupes Builder properties read from the file
067    /// `.jdbld.properties` in the root project.
068    protected Properties jdbldProps;
069    /// The command line.
070    protected CommandLine commandLine;
071    private final Path buildRoot;
072    private static final String RUNTIME_EXTENSIONS = "runtimeExtensions";
073    private final ClassLoader extClsLdr;
074    private AbstractRootProject rootProject;
075
076    /// Instantiates a new build project launcher. The classpath is scanned
077    /// for classes that implement [Project] but do not implement [Masked].
078    /// One of these must also implement the [RootProject] interface.
079    /// The latter is instantiated and registered as root project with all
080    /// other classes found as direct sub projects.
081    ///
082    /// @param classloader the classloader for the build project
083    /// @param buildRoot the build root
084    /// @param args the arguments. Flags are processed in the constructor,
085    /// command line arguments are processed in [runCommands].
086    ///
087    @SuppressWarnings({ "PMD.UseVarargs",
088        "PMD.ConstructorCallsOverridableMethod" })
089    public BuildProjectLauncher(ClassLoader classloader, Path buildRoot,
090            String[] args) {
091        this.buildRoot = buildRoot;
092        jdbldProps = propertiesFromFiles(buildRoot);
093        try {
094            commandLine = new DefaultParser().parse(baseOptions(), args);
095        } catch (ParseException e) {
096            configureLogging(buildRoot, jdbldProps);
097            throw new ConfigurationException().cause(e);
098        }
099        addCliProperties(jdbldProps, commandLine);
100        configureLogging(buildRoot, jdbldProps);
101        extClsLdr = addRuntimeExts(classloader);
102        regenerateRootProject();
103    }
104
105    @Override
106    public AbstractRootProject regenerateRootProject() {
107        var rootProjects = new ArrayList<Class<? extends RootProject>>();
108        var subprojects = new ArrayList<Class<? extends Project>>();
109        findProjects(extClsLdr, rootProjects, subprojects);
110        @SuppressWarnings("PMD.CloseResource")
111        var newRootProject = createProjects(buildRoot,
112            rootProjects.get(0), subprojects, jdbldProps, commandLine);
113        if (rootProject != null) {
114            rootProject.close();
115        }
116        rootProject = newRootProject;
117        return rootProject;
118    }
119
120    private ClassLoader addRuntimeExts(ClassLoader classloader) {
121        String[] coordinates = Arrays
122            .asList(jdbldProps.getProperty(RUNTIME_EXTENSIONS, "").split(","))
123            .stream()
124            .map(String::trim).filter(c -> !c.isBlank()).toArray(String[]::new);
125        if (coordinates.length == 0) {
126            return classloader;
127        }
128
129        // Resolve using maven repo
130        var cpUrls = resolveRequested(coordinates).mapMulti((jf, consumer) -> {
131            try {
132                consumer.accept(jf.path().toFile().toURI().toURL());
133            } catch (MalformedURLException e) {
134                logger.atWarning().withCause(e).log("Cannot convert %s to URL",
135                    jf);
136            }
137        }).toArray(URL[]::new);
138
139        // Return augmented classloader
140        return new URLClassLoader(cpUrls, classloader);
141    }
142
143    @Override
144    public void close() {
145        rootProject.close();
146    }
147
148    @SuppressWarnings({ "PMD.UseVarargs",
149        "PMD.AvoidInstantiatingObjectsInLoops" })
150    private Stream<JarFile> resolveRequested(String[] coordinates) {
151        ContextOverrides overrides = ContextOverrides.create()
152            .withUserSettings(true).build();
153        Runtime runtime = Runtimes.INSTANCE.getRuntime();
154        try (Context context = runtime.create(overrides)) {
155            CollectRequest collectRequest = new CollectRequest()
156                .setRepositories(context.remoteRepositories());
157            for (var coord : coordinates) {
158                collectRequest.addDependency(
159                    new Dependency(new DefaultArtifact(coord), "runtime"));
160            }
161
162            DependencyRequest dependencyRequest
163                = new DependencyRequest(collectRequest, null);
164            DependencyNode rootNode;
165            try {
166                rootNode = context.repositorySystem()
167                    .resolveDependencies(context.repositorySystemSession(),
168                        dependencyRequest)
169                    .getRoot();
170                PreorderNodeListGenerator nlg = new PreorderNodeListGenerator();
171                rootNode.accept(nlg);
172                List<DependencyNode> dependencyNodes = nlg.getNodes();
173                return dependencyNodes.stream()
174                    .filter(d -> d.getArtifact() != null)
175                    .map(d -> d.getArtifact().getFile().toPath())
176                    .map(p -> ResourceFactory
177                        .create(JarFileType, p));
178            } catch (DependencyResolutionException e) {
179                throw new ConfigurationException()
180                    .message("Cannot resolve: %s", e).cause(e);
181            }
182        }
183    }
184
185    @Override
186    public AbstractRootProject rootProject() {
187        return rootProject;
188    }
189
190    /// Execute the commands from the command line.
191    ///
192    /// @return true, if successful
193    ///
194    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
195        "PMD.AvoidInstantiatingObjectsInLoops" })
196    public boolean runCommands() {
197        for (var arg : commandLine.getArgs()) {
198            var parts = arg.split(":");
199            String resource = parts[parts.length - 1];
200            var cmdData = rootProject.lookupCommand(resource);
201            if (cmdData.requests().length == 0) {
202                rootProject.context().out()
203                    .println("Unknown command: " + resource);
204                throw new UnavailableException().from(rootProject);
205            }
206            String[] patterns = cmdData.patterns();
207            String[] without = cmdData.without();
208            if (parts.length > 1) {
209                patterns = new String[] { parts[0] };
210                without = new String[0];
211            }
212            for (var req : cmdData.requests()) {
213                if (!resources(rootProject.projects(patterns, without), req)
214                    // eliminate duplicates
215                    .collect(Collectors.toSet()).stream()
216                    // output generated resources
217                    .peek(r -> rootProject.context().out()
218                        .println(r.toString()))
219                    .map(r -> !(r instanceof FaultAware far)
220                        || !far.isFaulty())
221                    .reduce(true, (r1, r2) -> r1 && r2)) {
222                    return false;
223                }
224            }
225        }
226        return true;
227    }
228
229    /// This main can be used to start the user's JDrupes Builder
230    /// project from an IDE for debugging purposes. It expects that
231    /// the JDrupes Builder project has already been compiled (typically
232    /// by the IDE) and is available on the classpath.
233    ///
234    /// @param args the arguments
235    ///
236    @SuppressWarnings("PMD.SystemPrintln")
237    public static void main(String[] args) {
238        try {
239            if (!reportBuildException(() -> {
240                try (var bpl = new BuildProjectLauncher(
241                    Thread.currentThread().getContextClassLoader(),
242                    Path.of("").toAbsolutePath(), args)) {
243                    return bpl.runCommands();
244                }
245            })) {
246                java.lang.Runtime.getRuntime().exit(1);
247            }
248        } catch (BuildException e) {
249            if (e.getCause() == null) {
250                logger.atSevere().log("Build failed: %s",
251                    formatter().summary(e));
252            } else {
253                logger.atSevere().withCause(e).log("Build failed: %s",
254                    formatter().summary(e));
255            }
256            System.out.println(formatter().summary(e));
257            System.out.println(e.details());
258            java.lang.Runtime.getRuntime().exit(2);
259        }
260    }
261}