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 java.net.MalformedURLException;
023import java.net.URL;
024import java.net.URLClassLoader;
025import java.nio.file.Path;
026import java.util.ArrayList;
027import java.util.Arrays;
028import java.util.Properties;
029import java.util.TreeMap;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032import org.apache.commons.cli.CommandLine;
033import org.apache.commons.cli.DefaultParser;
034import org.apache.commons.cli.ParseException;
035import org.jdrupes.builder.api.BuildException;
036import org.jdrupes.builder.api.ConfigurationException;
037import org.jdrupes.builder.api.FaultAware;
038import org.jdrupes.builder.api.Launcher;
039import org.jdrupes.builder.api.Masked;
040import org.jdrupes.builder.api.Project;
041import org.jdrupes.builder.api.RootProject;
042import org.jdrupes.builder.api.UnavailableException;
043import org.jdrupes.builder.core.AbstractRootProject;
044import org.jdrupes.builder.java.JarFile;
045import org.jdrupes.builder.mvnrepo.MvnRepoLookup;
046import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
047
048/// An implementation of a [Launcher] that launches the build configuration.
049/// It expects that the JDrupes Builder project has already been compiled
050/// and its classes are available on the classpath.
051///
052public class BuildProjectLauncher extends AbstractLauncher {
053
054    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
055    /// The JDrupes Builder properties read from the file
056    /// `.jdbld.properties` in the root project.
057    protected Properties jdbldProps;
058    /// The command line.
059    protected CommandLine commandLine;
060    private final Path buildRoot;
061    private static final String RUNTIME_EXTENSIONS = "runtimeExtensions";
062    private final ClassLoader extClsLdr;
063    private AbstractRootProject rootProject;
064
065    /// Instantiates a new build project launcher. The classpath is scanned
066    /// for classes that implement [Project] but do not implement [Masked].
067    /// One of these must also implement the [RootProject] interface.
068    /// The latter is instantiated and registered as root project with all
069    /// other classes found as direct sub projects.
070    ///
071    /// @param classloader the classloader for the build project
072    /// @param buildRoot the build root
073    /// @param args the arguments. Flags are processed in the constructor,
074    /// command line arguments are processed in [runCommands].
075    ///
076    @SuppressWarnings({ "PMD.UseVarargs",
077        "PMD.ConstructorCallsOverridableMethod" })
078    public BuildProjectLauncher(ClassLoader classloader, Path buildRoot,
079            String[] args) {
080        this.buildRoot = buildRoot;
081        jdbldProps = propertiesFromFiles(buildRoot);
082        try {
083            commandLine = new DefaultParser().parse(baseOptions(), args);
084        } catch (ParseException e) {
085            doConfigureLogging(buildRoot, jdbldProps);
086            throw new ConfigurationException().cause(e);
087        }
088        addCliProperties(jdbldProps, commandLine);
089        doConfigureLogging(buildRoot, jdbldProps);
090        extClsLdr = addRuntimeExts(classloader);
091        regenerateRootProject();
092    }
093
094    /// Calls [configureLogging] with [buildRoot] and [jdbldProps].
095    /// Can be overridden by a derived class (usually with a noop)
096    /// to avoid the reconfiguration when launching a build from
097    /// jdbld.
098    ///
099    /// @param buildRoot the build root
100    /// @param jdbldProps the jdbld props
101    ///
102    protected void doConfigureLogging(Path buildRoot, Properties jdbldProps) {
103        configureLogging(buildRoot, jdbldProps);
104    }
105
106    @Override
107    public AbstractRootProject regenerateRootProject() {
108        var rootProjects = new ArrayList<Class<? extends RootProject>>();
109        var subprojects = new ArrayList<Class<? extends Project>>();
110        findProjects(extClsLdr, rootProjects, subprojects);
111        @SuppressWarnings("PMD.CloseResource")
112        var newRootProject = createProjects(buildRoot,
113            rootProjects.get(0), subprojects, jdbldProps, commandLine);
114        if (rootProject != null) {
115            rootProject.close();
116        }
117        rootProject = newRootProject;
118        return rootProject;
119    }
120
121    private ClassLoader addRuntimeExts(ClassLoader classloader) {
122        String[] coordinates = Arrays
123            .asList(jdbldProps.getProperty(RUNTIME_EXTENSIONS, "").split(","))
124            .stream()
125            .map(String::trim).filter(c -> !c.isBlank()).toArray(String[]::new);
126        if (coordinates.length == 0) {
127            return classloader;
128        }
129
130        // Resolve using maven repo
131        var cpUrls = resolveRequested(coordinates).mapMulti((jf, consumer) -> {
132            try {
133                consumer.accept(jf.path().toFile().toURI().toURL());
134            } catch (MalformedURLException e) {
135                logger.atWarning().withCause(e).log("Cannot convert %s to URL",
136                    jf);
137            }
138        }).toArray(URL[]::new);
139
140        // Return augmented classloader
141        return new URLClassLoader(cpUrls, classloader);
142    }
143
144    @Override
145    public void close() {
146        rootProject.close();
147    }
148
149    @SuppressWarnings({ "PMD.UseVarargs" })
150    private Stream<JarFile> resolveRequested(String[] coordinates) {
151        var lookup = new MvnRepoLookup().resolve(coordinates);
152        return lookup.resources(lookup.of(MvnRepoLibraryJarFileType));
153    }
154
155    @Override
156    public AbstractRootProject rootProject() {
157        return rootProject;
158    }
159
160    /// Execute the commands from the command line.
161    ///
162    /// @return true, if successful
163    ///
164    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
165        "PMD.AvoidInstantiatingObjectsInLoops" })
166    public boolean runCommands() {
167        if (commandLine.hasOption("help")
168            || commandLine.getArgs().length == 0) {
169            printHelp();
170            return true;
171        }
172        for (var arg : commandLine.getArgs()) {
173            var parts = arg.split(":");
174            String resource = parts[parts.length - 1];
175            var cmdData = rootProject.lookupCommand(resource);
176            if (cmdData.requests().length == 0) {
177                rootProject.context().out()
178                    .println("Unknown command: " + resource);
179                throw new UnavailableException().from(rootProject);
180            }
181            String[] patterns = cmdData.patterns();
182            String[] without = cmdData.without();
183            if (parts.length > 1) {
184                patterns = new String[] { parts[0] };
185                without = new String[0];
186            }
187            for (var req : cmdData.requests()) {
188                if (!resources(rootProject.projects(patterns, without), req)
189                    // eliminate duplicates
190                    .collect(Collectors.toSet()).stream()
191                    // output generated resources
192                    .peek(r -> rootProject.context().out()
193                        .println(r.toString()))
194                    .map(r -> !(r instanceof FaultAware far)
195                        || !far.isFaulty())
196                    .reduce(true, (r1, r2) -> r1 && r2)) {
197                    return false;
198                }
199            }
200        }
201        return true;
202    }
203
204    private void printHelp() {
205        rootProject.context().out()
206            .println("Usage: jdbld [Options] command ...");
207        rootProject.context().out().println();
208        var cmds = new TreeMap<>(rootProject.commands());
209        int maxNameLen = cmds.keySet().stream()
210            .mapToInt(String::length).max().orElse(0);
211        String fmt = "  - %-" + maxNameLen + "s   %s";
212        rootProject.context().out().println("Available commands:");
213        cmds.forEach((name, desc) -> rootProject.context().out()
214            .println(String.format(fmt, name, desc)));
215    }
216
217    /// This main can be used to start the user's JDrupes Builder
218    /// project from an IDE for debugging purposes. It expects that
219    /// the JDrupes Builder project has already been compiled (typically
220    /// by the IDE) and is available on the classpath.
221    ///
222    /// @param args the arguments
223    ///
224    @SuppressWarnings("PMD.SystemPrintln")
225    public static void main(String[] args) {
226        try {
227            if (!reportBuildException(() -> {
228                try (var bpl = new BuildProjectLauncher(
229                    Thread.currentThread().getContextClassLoader(),
230                    Path.of("").toAbsolutePath(), args)) {
231                    return bpl.runCommands();
232                }
233            })) {
234                Runtime.getRuntime().exit(1);
235            }
236        } catch (BuildException e) {
237            if (e.getCause() == null) {
238                logger.atSevere().log("Build failed: %s",
239                    formatter().summary(e));
240            } else {
241                logger.atSevere().withCause(e).log("Build failed: %s",
242                    formatter().summary(e));
243            }
244            System.out.println(formatter().summary(e));
245            System.out.println(e.details());
246            Runtime.getRuntime().exit(2);
247        }
248    }
249}