001/*
002 * JDrupes Builder
003 * Copyright (C) 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.nodejs;
020
021import com.google.common.flogger.FluentLogger;
022import java.io.File;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.OutputStream;
026import java.lang.ProcessBuilder.Redirect;
027import java.nio.file.Path;
028import java.util.ArrayList;
029import java.util.Arrays;
030import java.util.List;
031import java.util.function.Function;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034import org.jdrupes.builder.api.BuildException;
035import org.jdrupes.builder.api.Cleanliness;
036import org.jdrupes.builder.api.ExecResult;
037import org.jdrupes.builder.api.FileResource;
038import org.jdrupes.builder.api.FileTree;
039import org.jdrupes.builder.api.Project;
040import org.jdrupes.builder.api.Renamable;
041import org.jdrupes.builder.api.Resource;
042import org.jdrupes.builder.api.ResourceProvider;
043import org.jdrupes.builder.api.ResourceRequest;
044import org.jdrupes.builder.api.ResourceType;
045import static org.jdrupes.builder.api.ResourceType.*;
046import org.jdrupes.builder.api.Resources;
047import org.jdrupes.builder.core.AbstractProvider;
048
049/// A provider for [execution results][ExecResult]s from invoking npm.
050/// The provider produces resources in response to requests for
051/// [ExecResult]'s where the request's [ResourceRequest#name()] matches
052/// this [provider's name][ResourceProvider#name()].
053/// 
054///   * The provider first checks if a file `package.json` exists, else it
055///     fails. If no directory `node_modules` exists or `package.json`
056///     is newer than `node_modules/.package-lock.json` it invokes `npm init`.
057/// 
058///   * Then, the provider retrieves all resources added by [#required]. While
059///     the provider itself does not process these resources, it is assumed
060///     that they are processed by the npm command and therefore need to be
061///     available.
062/// 
063///   * The provider invokes the function set with [#provided] and
064///     collects all resources. If the provided resources exist and no
065///     resource from `required` is newer then the provided resources found,
066///     the provider returns a result that indicates successful invocation.
067///     The date of the result is set to the newest date from the provided
068///     resources and the (existing) resources are attached.  
069/// 
070///   * Else, the provider invokes npm, calls the function set with
071///     `provided` again and adds the result to the [ExecResult] that
072///     it returns.
073///
074/// The provider also uses the function set with [#provided] to determine
075/// the resources to be removed when it is invoked with a request for
076/// [Cleanliness].
077///
078public class NpmExecutor extends AbstractProvider implements Renamable {
079
080    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
081    private final Project project;
082    private final List<String> arguments = new ArrayList<>();
083    private final List<Stream<Resource>> requiredResources = new ArrayList<>();
084    private Function<Project, Stream<Resource>> getProvided
085        = _ -> Stream.empty();
086
087    /// Initializes a new NPM executor.
088    ///
089    /// @param project the project
090    ///
091    public NpmExecutor(Project project) {
092        this.project = project;
093        rename(NpmExecutor.class.getSimpleName() + " in " + project);
094    }
095
096    /// Name.
097    ///
098    /// @param name the name
099    /// @return the npm executor
100    ///
101    @Override
102    public NpmExecutor name(String name) {
103        rename(name);
104        return this;
105    }
106
107    /// Add the given arguments.
108    ///
109    /// @param args the arguments
110    /// @return the npm executor
111    ///
112    public NpmExecutor args(String... args) {
113        arguments.addAll(Arrays.asList(args));
114        return this;
115    }
116
117    /// Add the given [Stream] of resources to the required resources.
118    ///
119    /// @param resources the resources
120    /// @return the npm executor
121    ///
122    public NpmExecutor required(Stream<Resource> resources) {
123        requiredResources.add(resources);
124        return this;
125    }
126
127    /// Convenience method to add a [FileTree] to the required resources.
128    /// If `root` is a relative path, it is resolved against the project's
129    /// directory. 
130    ///
131    /// @param root the root
132    /// @param pattern the pattern
133    /// @return the npm executor
134    ///
135    public NpmExecutor required(Path root, String pattern) {
136        requiredResources
137            .add(Stream.of(FileTree.create(project, root, pattern)));
138        return this;
139    }
140
141    /// Convenience method to add a [FileResource] to the required resources.
142    /// If `path` is relative, it is resolved against the project's directory. 
143    ///
144    /// @param root the root
145    /// @return the npm executor
146    ///
147    public NpmExecutor required(Path root) {
148        requiredResources.add(Stream.of(FileResource.create(project, root)));
149        return this;
150    }
151
152    /// Sets the function used to determine the resources provided by this
153    /// provider.
154    ///
155    /// @param resources the resources
156    /// @return the npm executor
157    ///
158    public NpmExecutor provided(Function<Project, Stream<Resource>> resources) {
159        this.getProvided = resources;
160        return this;
161    }
162
163    @Override
164    protected <T extends Resource> Stream<T>
165            doProvide(ResourceRequest<T> requested) {
166        if (requested.accepts(CleanlinessType)) {
167            getProvided.apply(project).forEach(Resource::cleanup);
168            return Stream.empty();
169        }
170        if (!requested.accepts(ExecResultType)
171            || requested.name().map(n -> !n.equals(name())).orElse(false)) {
172            return Stream.empty();
173        }
174
175        // Check prerequisites
176        File packageJson = project.directory().resolve("package.json").toFile();
177        if (!packageJson.canRead()) {
178            throw new BuildException().from(this)
179                .message("No package.json in %s", project);
180        }
181        File dotPackageLock = project.directory()
182            .resolve("node_modules/.package-lock.json").toFile();
183        if (!project.directory().resolve("node_modules").toFile().exists()
184            || !dotPackageLock.exists()
185            || packageJson.lastModified() > dotPackageLock.lastModified()) {
186            logger.atConfig().log("Updating node_modules in %s", project);
187            runNpm(project, List.of("install"));
188        }
189
190        // Make sure that the required resources exists
191        var required = newResource(new ResourceType<Resources<Resource>>() {});
192        requiredResources.stream().forEach(required::addAll);
193
194        // Get (previously) provided and check if up-to-date
195        var provided = newResource(new ResourceType<Resources<Resource>>() {});
196        provided.addAll(getProvided.apply(project));
197        if (required.asOf().isPresent() && provided.asOf().isPresent()
198            && !required.asOf().get().isAfter(provided.asOf().get())) {
199            var execResult = newResource(ExecResultType, this,
200                "existing " + provided.stream().map(Resource::toString)
201                    .collect(Collectors.joining(", ")),
202                0, provided.stream());
203            @SuppressWarnings("unchecked")
204            var result = (Stream<T>) Stream.of(execResult);
205            return result;
206
207        }
208
209        return runNpm(project, arguments);
210    }
211
212    private <T extends Resource> Stream<T> runNpm(
213            Project project, List<String> arguments) {
214        List<String> command = new ArrayList<>(List.of("npm"));
215        command.addAll(arguments);
216        ProcessBuilder processBuilder = new ProcessBuilder(command)
217            .directory(project.directory().toFile())
218            .redirectInput(Redirect.INHERIT);
219        try {
220            Process process = processBuilder.start();
221            copyData(process.getInputStream(), context().out());
222            copyData(process.getErrorStream(), context().error());
223            @SuppressWarnings("unchecked")
224            var result = (Stream<T>) Stream.of(newResource(ExecResultType, this,
225                "[" + project.name() + "]$ "
226                    + command.stream().collect(Collectors.joining(" ")),
227                process.waitFor(), getProvided.apply(project)));
228            return result;
229        } catch (IOException | InterruptedException e) {
230            throw new BuildException().from(this).cause(e);
231        }
232    }
233
234    private void copyData(InputStream source, OutputStream sink) {
235        Thread.startVirtualThread(() -> {
236            try (source) {
237                source.transferTo(sink);
238            } catch (IOException e) { // NOPMD
239            }
240        });
241    }
242}