001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 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.core;
020
021import java.io.IOException;
022import java.lang.reflect.InvocationTargetException;
023import java.nio.file.Files;
024import java.nio.file.Path;
025import java.util.Collections;
026import java.util.EnumSet;
027import java.util.HashMap;
028import java.util.Map;
029import java.util.Map.Entry;
030import java.util.Objects;
031import java.util.Optional;
032import java.util.Set;
033import java.util.concurrent.ConcurrentHashMap;
034import java.util.concurrent.ExecutionException;
035import java.util.concurrent.Future;
036import java.util.stream.Stream;
037import org.jdrupes.builder.api.BuildException;
038import org.jdrupes.builder.api.Cleanliness;
039import org.jdrupes.builder.api.Generator;
040import org.jdrupes.builder.api.Intend;
041import static org.jdrupes.builder.api.Intend.*;
042import org.jdrupes.builder.api.NamedParameter;
043import org.jdrupes.builder.api.Project;
044import org.jdrupes.builder.api.PropertyKey;
045import org.jdrupes.builder.api.Resource;
046import org.jdrupes.builder.api.ResourceProvider;
047import org.jdrupes.builder.api.ResourceRequest;
048import org.jdrupes.builder.api.ResourceType;
049import org.jdrupes.builder.api.RootProject;
050
051/// A default implementation of a [Project].
052///
053@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.GodClass" })
054public abstract class AbstractProject extends AbstractProvider
055        implements Project {
056
057    private Map<Class<? extends Project>, Future<Project>> projects;
058    private static ThreadLocal<AbstractProject> fallbackParent
059        = new ThreadLocal<>();
060    private static Path jdbldDirectory = Path.of("marker:jdbldDirectory");
061    private final AbstractProject parent;
062    private final String projectName;
063    private final Path projectDirectory;
064    private final Map<ResourceProvider, Intend> providers
065        = new ConcurrentHashMap<>();
066    @SuppressWarnings("PMD.UseConcurrentHashMap")
067    private final Map<PropertyKey, Object> properties = new HashMap<>();
068    // Only non null in the root project
069    private DefaultBuildContext context;
070    private Map<String, ResourceRequest<?>[]> commands;
071
072    /// Named parameter for specifying the parent project.
073    ///
074    /// @param parentProject the parent project
075    /// @return the named parameter
076    ///
077    protected static NamedParameter<Class<? extends Project>>
078            parent(Class<? extends Project> parentProject) {
079        return new NamedParameter<>("parent", parentProject);
080    }
081
082    /// Named parameter for specifying the name.
083    ///
084    /// @param name the name
085    /// @return the named parameter
086    ///
087    protected static NamedParameter<String> name(String name) {
088        return new NamedParameter<>("name", name);
089    }
090
091    /// Named parameter for specifying the directory.
092    ///
093    /// @param directory the directory
094    /// @return the named parameter
095    ///
096    protected static NamedParameter<Path> directory(Path directory) {
097        return new NamedParameter<>("directory", directory);
098    }
099
100    /// Hack to pass `context().jdbldDirectory()` as named parameter
101    /// for the directory to the constructor. This is required because
102    /// you cannot "refer to an instance method while explicitly invoking
103    /// a constructor". 
104    ///
105    /// @return the named parameter
106    ///
107    protected static NamedParameter<Path> jdbldDirectory() {
108        return new NamedParameter<>("directory", jdbldDirectory);
109    }
110
111    /// Base class constructor for all projects. The behavior depends 
112    /// on whether the project is a root project (implements [RootProject])
113    /// or a subproject and on whether the project specifies a parent project.
114    ///
115    /// [RootProject]s must invoke this constructor with a null parent project
116    /// class.
117    ///
118    /// A sub project that wants to specify a parent project must invoke this
119    /// constructor with the parent project's class. If a sub project does not
120    /// specify a parent project, the root project is used as parent. In both
121    /// cases, the constructor adds a [Intend#Forward] dependency between the
122    /// parent project and the new project. This can then be overridden in the
123    /// sub project's constructor.
124    ///
125    /// @param params the named parameters
126    ///   * parent - the class of the parent project
127    ///   * name - the name of the project. If not provided the name is
128    ///     set to the (simple) class name
129    ///   * directory - the directory of the project. If not provided,
130    ///     the directory is set to the name with uppercase letters
131    ///     converted to lowercase for subprojects. For root projects
132    ///     the directory is always set to the current working
133    ///
134    @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod",
135        "PMD.UseLocaleWithCaseConversions", "PMD.AvoidCatchingGenericException",
136        "PMD.CognitiveComplexity" })
137    protected AbstractProject(NamedParameter<?>... params) {
138        // Evaluate parent project
139        var parentProject = NamedParameter.<
140                Class<? extends Project>> get(params, "parent", null);
141        if (parentProject == null) {
142            parent = fallbackParent.get();
143            if (this instanceof RootProject) {
144                if (parent != null) {
145                    throw new BuildException("Root project of type "
146                        + getClass().getSimpleName()
147                        + " cannot be a sub project.");
148                }
149                // ConcurrentHashMap does not support null values.
150                projects = Collections.synchronizedMap(new HashMap<>());
151                context = new DefaultBuildContext();
152                commands = new HashMap<>(Map.of(
153                    "clean", new ResourceRequest<?>[] {
154                        new ResourceRequest<Cleanliness>(
155                            new ResourceType<>() {}) }));
156            }
157        } else {
158            parent = (AbstractProject) project(parentProject);
159        }
160
161        // Set name and directory, add fallback dependency
162        var name = NamedParameter.<String> get(params, "name",
163            () -> getClass().getSimpleName());
164        projectName = name;
165        var directory = NamedParameter.<Path> get(params, "directory", null);
166        if (directory == jdbldDirectory) { // NOPMD
167            directory = context().jdbldDirectory();
168        }
169        if (parent == null) {
170            if (directory != null) {
171                throw new BuildException("Root project of type "
172                    + getClass().getSimpleName()
173                    + " cannot specify a directory.");
174            }
175            projectDirectory = Path.of("").toAbsolutePath();
176        } else {
177            if (directory == null) {
178                directory = Path.of(projectName.toLowerCase());
179            }
180            projectDirectory = parent.directory().resolve(directory);
181            // Fallback, will be replaced when the parent explicitly adds a
182            // dependency.
183            parent.dependency(Forward, this);
184        }
185        try {
186            rootProject().prepareProject(this);
187        } catch (Exception e) {
188            throw new BuildException(e);
189        }
190    }
191
192    @Override
193    public final RootProject rootProject() {
194        if (this instanceof RootProject root) {
195            return root;
196        }
197        // The method may be called (indirectly) from the constructor
198        // of a subproject, that specifies its parent project class, to
199        // get the parent project instance. In this case, the new
200        // project's parent attribute has not been set yet and we have
201        // to use the fallback.
202        return Optional.ofNullable(parent).orElse(fallbackParent.get())
203            .rootProject();
204    }
205
206    @Override
207    public ResourceProvider project(Class<? extends Project> prjCls) {
208        if (this.getClass().equals(prjCls)) {
209            return this;
210        }
211        if (projects == null) {
212            return rootProject().project(prjCls);
213        }
214
215        // "this" is the root project.
216        try {
217            return projects.computeIfAbsent(prjCls, k -> {
218                return context().executor().submit(() -> {
219                    try {
220                        fallbackParent.set(this);
221                        return (Project) k.getConstructor().newInstance();
222                    } catch (SecurityException | InstantiationException
223                            | IllegalAccessException
224                            | InvocationTargetException
225                            | NoSuchMethodException e) {
226                        throw new IllegalArgumentException(e);
227                    } finally {
228                        fallbackParent.set(null);
229                    }
230                });
231            }).get();
232        } catch (InterruptedException | ExecutionException e) {
233            throw new BuildException(e);
234        }
235    }
236
237    @Override
238    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
239    public String name() {
240        return projectName;
241    }
242
243    @Override
244    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
245    public Path directory() {
246        return projectDirectory;
247    }
248
249    /// Generator.
250    ///
251    /// @param provider the provider
252    /// @return the project
253    ///
254    @Override
255    public Project generator(Generator provider) {
256        providers.put(provider, Supply);
257        return this;
258    }
259
260    @Override
261    public Project dependency(Intend intend, ResourceProvider provider) {
262        providers.put(provider, intend);
263        return this;
264    }
265
266    @Override
267    public Stream<ResourceProvider> providers(Set<Intend> intends) {
268        return providers.entrySet().stream()
269            .filter(e -> intends.contains(e.getValue())).map(Entry::getKey);
270    }
271
272    @Override
273    public <T extends Resource> Stream<T> getFrom(
274            Stream<ResourceProvider> providers, ResourceRequest<T> request) {
275        return providers.map(p -> context().<T> get(p, request))
276            // Terminate stream to start all tasks for evaluating the futures
277            .toList().stream().flatMap(s -> s);
278    }
279
280    @Override
281    public DefaultBuildContext context() {
282        return ((AbstractProject) rootProject()).context;
283    }
284
285    @Override
286    @SuppressWarnings("unchecked")
287    public <T> T get(PropertyKey property) {
288        return (T) Optional.ofNullable(properties.get(property))
289            .orElseGet(() -> {
290                if (parent != null) {
291                    return parent.get(property);
292                }
293                return property.defaultValue();
294            });
295    }
296
297    @Override
298    public AbstractProject set(PropertyKey property, Object value) {
299        if (!property.type().isAssignableFrom(value.getClass())) {
300            throw new IllegalArgumentException("Value for " + property
301                + " must be of type " + property.type());
302        }
303        properties.put(property, value);
304        return this;
305    }
306
307    /// A project itself does not provide any resources. Rather, requests
308    /// for resources are forwarded to the project's providers with intend
309    /// [Intend#Forward], [Intend#Expose] or [Intend#Supply].
310    ///
311    /// @param <R> the generic type
312    /// @param requested the requested
313    /// @return the provided resources
314    ///
315    @Override
316    protected <R extends Resource> Stream<R>
317            doProvide(ResourceRequest<R> requested) {
318        return getFrom(providers(EnumSet.of(Forward, Expose, Supply)),
319            requested);
320    }
321
322    /// Define command, see [RootProject#commandAlias].
323    ///
324    /// @param name the name
325    /// @param requests the requests
326    /// @return the root project
327    ///
328    public RootProject commandAlias(String name,
329            ResourceRequest<?>... requests) {
330        if (commands == null) {
331            throw new BuildException("Commands can only be defined for"
332                + " the root project.");
333        }
334        commands.put(name, requests);
335        return (RootProject) this;
336    }
337
338    /* default */ ResourceRequest<?>[] lookupCommand(String name) {
339        return commands.getOrDefault(name, new ResourceRequest[0]);
340    }
341
342    /// Convenience method for reading the content of a file into a
343    /// String.
344    ///
345    /// @param path the path
346    /// @return the string
347    ///
348    @SuppressWarnings("PMD.PreserveStackTrace")
349    public String readString(Path path) {
350        try {
351            return Files.readString(path);
352        } catch (IOException e) {
353            throw new BuildException("Cannot read file: " + e.getMessage());
354        }
355    }
356
357    @Override
358    public int hashCode() {
359        return Objects.hash(projectDirectory, projectName);
360    }
361
362    @Override
363    public boolean equals(Object obj) {
364        if (this == obj) {
365            return true;
366        }
367        if (obj == null) {
368            return false;
369        }
370        if (getClass() != obj.getClass()) {
371            return false;
372        }
373        AbstractProject other = (AbstractProject) obj;
374        return Objects.equals(projectDirectory, other.projectDirectory)
375            && Objects.equals(projectName, other.projectName);
376    }
377
378    /// To string.
379    ///
380    /// @return the string
381    ///
382    @Override
383    public String toString() {
384        var relDir = rootProject().directory().relativize(directory());
385        return "Project " + name() + (relDir.toString().isBlank() ? ""
386            : (" (in " + relDir + ")"));
387    }
388
389}