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.core;
020
021import com.google.common.flogger.FluentLogger;
022import java.io.IOException;
023import java.io.InputStream;
024import java.io.OutputStream;
025import java.lang.ProcessBuilder.Redirect;
026import java.nio.file.Path;
027import java.time.Instant;
028import java.util.ArrayList;
029import java.util.Arrays;
030import java.util.Collection;
031import java.util.Collections;
032import java.util.List;
033import java.util.Objects;
034import java.util.function.Function;
035import java.util.stream.Collectors;
036import java.util.stream.Stream;
037import org.jdrupes.builder.api.BuildException;
038import org.jdrupes.builder.api.Cleanliness;
039import org.jdrupes.builder.api.ConfigurationException;
040import org.jdrupes.builder.api.ExecResult;
041import org.jdrupes.builder.api.FileResource;
042import org.jdrupes.builder.api.FileTree;
043import org.jdrupes.builder.api.Project;
044import org.jdrupes.builder.api.Renamable;
045import org.jdrupes.builder.api.RequiredResourceSupport;
046import org.jdrupes.builder.api.Resource;
047import org.jdrupes.builder.api.ResourceProvider;
048import org.jdrupes.builder.api.ResourceRequest;
049import org.jdrupes.builder.api.ResourceType;
050import static org.jdrupes.builder.api.ResourceType.*;
051import org.jdrupes.builder.api.Resources;
052
053/// A provider of [execution results][ExecResult] from invoking a script
054/// executed by a configurable [interpreter][#interpreter(Path)].
055/// The provider generates resources in response to requests for
056/// [ExecResult] whose [name][ResourceRequest#name()] matches this
057/// [provider's name][ResourceProvider#name()].
058/// 
059///   * The working directory is the project directory.
060/// 
061///   * The provider retrieves all resources added by [#required]. While
062///     the provider itself does not process these resources, it is assumed
063///     that they are processed by the script and therefore need to be
064///     available.
065/// 
066///   * The provider invokes the function configured with [#output] and
067///     collects all resources. If the generated resources exist and no
068///     resource from `required` is newer than the generated resources found,
069///     the provider returns a result that indicates successful invocation.
070///     The date of the result is set to the newest date from the generated
071///     resources and the (existing) resources are attached.  
072/// 
073///   * Else, the provider executes the script, calls the function set with
074///     [#output] again and adds the result to the [ExecResult] that
075///     it returns.
076/// 
077/// The generated resources can also be provided directly (i.e. not as part
078/// of an [ExecResult]) in response to a configurable resource request, see
079/// [#provideResources(ResourceRequest, Function)].
080///
081/// The provider also uses the function set with [#output] to determine
082/// the resources to be removed when it is invoked with a request for
083/// [Cleanliness].
084///
085public class ScriptExecutor extends AbstractProvider
086        implements Renamable, RequiredResourceSupport {
087    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
088    private final Project project;
089    private final List<String> interpreterFlags = new ArrayList<>();
090    private Path interpreter = Path.of("/usr/bin/bash");
091    private Path scriptFile;
092    private String scriptArgumentFlag = "-c";
093    private String script;
094    private final StreamCollector<String> arguments
095        = new StreamCollector<>(true);
096    private final StreamCollector<Resource> requiredResources
097        = new StreamCollector<>(true);
098    private Function<Project, Stream<Resource>> getOutput
099        = _ -> Stream.empty();
100    private ResourceRequest<?> requestForGenerated;
101
102    /// Initializes a new script executor.
103    ///
104    /// @param project the project
105    ///
106    public ScriptExecutor(Project project) {
107        this.project = project;
108        rename(ScriptExecutor.class.getSimpleName() + " in " + project);
109    }
110
111    /// Returns the project that this provider belongs to.
112    ///
113    /// @return the project
114    ///
115    public Project project() {
116        return project;
117    }
118
119    @Override
120    public ScriptExecutor name(String name) {
121        rename(name);
122        return this;
123    }
124
125    /// Sets the path to the interpreter that is to be invoked.
126    ///
127    /// @param interpreter the interpreter
128    /// @return the script executor
129    ///
130    public ScriptExecutor interpreter(Path interpreter) {
131        this.interpreter = interpreter;
132        return this;
133    }
134
135    /// Adds the given flags to the interpreter invocation.
136    ///
137    /// @param flags the flags
138    /// @return the script executor
139    ///
140    public ScriptExecutor interpreterFlags(String... flags) {
141        interpreterFlags.addAll(Arrays.asList(flags));
142        return this;
143    }
144
145    /// Sets the script file to be executed.
146    ///
147    /// @param script the script
148    /// @return the script executor
149    ///
150    public ScriptExecutor scriptFile(Path script) {
151        if (this.script != null) {
152            throw new ConfigurationException().from(this).message(
153                "Either script or scriptFile may be set");
154        }
155        this.scriptFile = project().directory().resolve(script);
156        return this;
157    }
158
159    /// Sets the flag that precedes a script passed to the interpreter
160    /// on the command line. Defaults to "-c".
161    ///
162    /// @param flag the flag
163    /// @return the script executor
164    ///
165    public ScriptExecutor scriptArgumentFlag(String flag) {
166        scriptArgumentFlag = flag;
167        return this;
168    }
169
170    /// Sets the script to be executed.
171    ///
172    /// @param script the script
173    /// @return the script executor
174    ///
175    public ScriptExecutor script(String script) {
176        if (scriptFile != null) {
177            throw new ConfigurationException().from(this).message(
178                "Either script or scriptFile may be set");
179        }
180        this.script = script;
181        return this;
182    }
183
184    /// Add the given arguments as arguments of the script. Note that
185    /// the absolute path of the script or, when using [#script(String)],
186    /// this provider's name is automatically added as the first
187    /// argument, before the arguments specified by this method.
188    ///
189    /// @param args the arguments
190    /// @return the script executor
191    ///
192    public ScriptExecutor args(String... args) {
193        arguments.add(Arrays.asList(args).stream());
194        return this;
195    }
196
197    /// Add the strings from the stream as arguments of the script,
198    /// see [#args(String...)].
199    ///
200    /// @param args the args
201    /// @return the script executor
202    ///
203    public ScriptExecutor args(Stream<String> args) {
204        arguments.add(args);
205        return this;
206    }
207
208    /// Required.
209    ///
210    /// @param resources the resources
211    /// @return the script executor
212    ///
213    @Override
214    public ScriptExecutor required(Stream<? extends Resource> resources) {
215        requiredResources.add(resources);
216        return this;
217    }
218
219    @Override
220    public ScriptExecutor required(Path root, String pattern) {
221        requiredResources
222            .add(Stream.of(FileTree.of(project, root, pattern)));
223        return this;
224    }
225
226    @Override
227    public ScriptExecutor required(Path file) {
228        requiredResources.add(
229            Stream.of(FileResource.of(project.directory().resolve(file))));
230        return this;
231    }
232
233    /// Sets the function used to determine the resources generated by this
234    /// provider. The function is evaluated both for incremental
235    /// up-to-date checks, for determining the resources returned
236    /// after script execution, and for determining the resources to be
237    /// cleaned.
238    ///
239    /// @param resources the function that provides the results as resources
240    /// @return the script executor
241    ///
242    public ScriptExecutor output(
243            Function<Project, Stream<Resource>> resources) {
244        this.getOutput = resources;
245        return this;
246    }
247
248    /// Provide the generated resources directly in response to a requests
249    /// like the given prototype request. Invoking this method implies a
250    /// call to [#output(Function)] with `resources`.
251    ///
252    /// @param proto defines the kind of request that the script executor
253    /// should respond to with the generated resources
254    /// @param resources the function that provides the results as resources
255    /// @return the script executor
256    ///
257    public ScriptExecutor provideResources(ResourceRequest<?> proto,
258            Function<Project, Stream<Resource>> resources) {
259        requestForGenerated = proto;
260        return output(resources);
261    }
262
263    @Override
264    protected <T extends Resource> Collection<T>
265            doProvide(ResourceRequest<T> request) {
266        if (request.accepts(CleanlinessType)) {
267            getOutput.apply(project).forEach(Resource::cleanup);
268            return Collections.emptyList();
269        }
270
271        // Handle request for generated resources
272        if (requestForGenerated != null
273            && request.accepts(requestForGenerated.type())
274            && (requestForGenerated.name().isEmpty()
275                || Objects.equals(requestForGenerated.name().get(),
276                    request.name().orElse(null)))) {
277            // No need to evaluate for most special type because
278            // everything is derived from the exec result
279            return provideGenerated();
280        }
281
282        // Check for and handle request for execution result
283        if (!request.accepts(ExecResultType)
284            || !name().equals(request.name().orElse(null))) {
285            return Collections.emptyList();
286        }
287        // Always evaluate for the most special type
288        if (!request.type().equals(ExecResultType)) {
289            @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
290            var result = (Collection<T>) resources(of(ExecResultType)
291                .withName(name())).toList();
292            return result;
293        }
294
295        // Make sure that the required resources are retrieved and exist
296        var required = Resources.of(new ResourceType<Resources<Resource>>() {});
297        required.addAll(requiredResources.stream());
298
299        // Get (previously) provided and check if up-to-date
300        var existing = Resources.of(new ResourceType<Resources<Resource>>() {});
301        existing.addAll(getOutput.apply(project));
302        if (required.asOf().isPresent() && existing.asOf().isPresent()
303            && !required.asOf().get().isAfter(existing.asOf().get())) {
304            logger.atFine().log("Output from %s is up to date", this);
305            @SuppressWarnings("unchecked")
306            var result = (T) ExecResult.of(this,
307                "existing " + existing.stream().map(Resource::toString)
308                    .collect(Collectors.joining(", ")),
309                0, existing.stream()).asOf(existing.asOf().get());
310            return List.of(result);
311        }
312        return runScript(project);
313    }
314
315    private <T extends Resource> Collection<T> runScript(Project project) {
316        logger.atFine().log("Running %s with %s", this, interpreter);
317        List<String> command
318            = new ArrayList<>(List.of(interpreter.toString()));
319        command.addAll(interpreterFlags);
320        if (scriptFile != null) {
321            command.add(scriptFile.toString());
322        }
323        if (script != null) {
324            command.add(scriptArgumentFlag);
325            command.add(script);
326            command.add(name());
327        }
328        arguments.stream().forEach(command::add);
329        ProcessBuilder processBuilder = new ProcessBuilder(command)
330            .directory(project.directory().toFile())
331            .redirectInput(Redirect.INHERIT);
332        try {
333            Process process = processBuilder.start();
334            copyData(process.getInputStream(), context().out());
335            copyData(process.getErrorStream(), context().error());
336            int exitValue = process.waitFor();
337            if (exitValue != 0) {
338                throw new BuildException().from(this)
339                    .message("Interpreter exited with %d", exitValue);
340            }
341            @SuppressWarnings("unchecked")
342            var result = (Collection<T>) List.of(ExecResult.of(this,
343                "[" + project.name() + "]$ ... "
344                    + arguments.stream().collect(Collectors.joining(" ")),
345                exitValue, getOutput.apply(project))
346                .asOf(Instant.now()));
347            return result;
348        } catch (IOException | InterruptedException e) {
349            throw new BuildException().from(this).cause(e);
350        }
351    }
352
353    private void copyData(InputStream source, OutputStream sink) {
354        Thread.startVirtualThread(() -> {
355            try (source) {
356                source.transferTo(sink);
357            } catch (IOException e) {
358                throw new BuildException().from(this).cause(e);
359            }
360        });
361    }
362
363    private <T extends Resource> Collection<T> provideGenerated() {
364        // Request execution result
365        var execResult = resources(of(ExecResultType).withName(name()));
366        @SuppressWarnings("unchecked")
367
368        // Extract generated
369        var generated = execResult.map(r -> (Stream<T>) r.resources())
370            .flatMap(s -> s).toList();
371        return generated;
372    }
373
374    /// To string.
375    ///
376    /// @return the string
377    ///
378    @Override
379    public String toString() {
380        return super.toString() + "[" + project().name() + "]";
381    }
382}