001package org.itsallcode.process;
002
003import static java.util.stream.Collectors.joining;
004
005import java.io.IOException;
006import java.io.UncheckedIOException;
007import java.lang.Thread.UncaughtExceptionHandler;
008import java.nio.file.Path;
009import java.time.Duration;
010import java.util.List;
011import java.util.concurrent.Executor;
012import java.util.logging.Level;
013import java.util.logging.Logger;
014
015/**
016 * Builder for {@link SimpleProcess}. Create a new instance with
017 * {@link #create()}.
018 */
019public final class SimpleProcessBuilder {
020    private final ProcessBuilder processBuilder;
021    private Duration streamCloseTimeout = Duration.ofSeconds(1);
022    private Executor executor;
023    private Level streamLogLevel = Level.FINE;
024
025    private SimpleProcessBuilder() {
026        this.processBuilder = new ProcessBuilder();
027    }
028
029    /**
030     * Create a new instance.
031     * 
032     * @return new {@link SimpleProcessBuilder}
033     */
034    public static SimpleProcessBuilder create() {
035        return new SimpleProcessBuilder();
036    }
037
038    /**
039     * Set program and arguments.
040     * 
041     * @param command program and arguments
042     * @return {@code this} for fluent programming
043     * @see ProcessBuilder#command(String...)
044     */
045    @SuppressWarnings("java:S923") // Using varargs by intention
046    public SimpleProcessBuilder command(final String... command) {
047        this.processBuilder.command(command);
048        return this;
049    }
050
051    /**
052     * Set program and arguments.
053     * 
054     * @param command program and arguments
055     * @return {@code this} for fluent programming
056     * @see ProcessBuilder#command(List)
057     */
058    public SimpleProcessBuilder command(final List<String> command) {
059        this.processBuilder.command(command);
060        return this;
061    }
062
063    /**
064     * Set working directory to the current process's working directory.
065     * 
066     * @return {@code this} for fluent programming
067     * @see ProcessBuilder#directory(java.io.File)
068     */
069    public SimpleProcessBuilder currentProcessWorkingDir() {
070        this.processBuilder.directory(null);
071        return this;
072    }
073
074    /**
075     * Set working directory.
076     * 
077     * @param workingDir working directory
078     * @return {@code this} for fluent programming
079     * @see ProcessBuilder#directory(java.io.File)
080     */
081    public SimpleProcessBuilder workingDir(final Path workingDir) {
082        this.processBuilder.directory(workingDir.toFile());
083        return this;
084    }
085
086    /**
087     * Redirect the error stream to the output stream if this is {@code true}.
088     * 
089     * @param redirectErrorStream the new property value, default: {@code false}
090     * @return {@code this} for fluent programming
091     * @see ProcessBuilder#redirectErrorStream(boolean)
092     */
093    public SimpleProcessBuilder redirectErrorStream(final boolean redirectErrorStream) {
094        this.processBuilder.redirectErrorStream(redirectErrorStream);
095        return this;
096    }
097
098    /**
099     * Set the timeout for closing the asynchronous stream readers.
100     * 
101     * @param streamCloseTimeout timeout
102     * @return {@code this} for fluent programming
103     */
104    public SimpleProcessBuilder setStreamCloseTimeout(final Duration streamCloseTimeout) {
105        this.streamCloseTimeout = streamCloseTimeout;
106        return this;
107    }
108
109    /**
110     * Set a custom executor for asynchronous stream readers.
111     * <p>
112     * Default: Create new threads on demand.
113     * 
114     * @param executor executor
115     * @return {@code this} for fluent programming
116     */
117    public SimpleProcessBuilder streamConsumerExecutor(final Executor executor) {
118        this.executor = executor;
119        return this;
120    }
121
122    /**
123     * Log level for the process's stdout and stderr.
124     * <p>
125     * Default: {@link Level#FINE}
126     * 
127     * @param streamLogLevel log level
128     * @return {@code this} for fluent programming
129     */
130    public SimpleProcessBuilder streamLogLevel(Level streamLogLevel) {
131        this.streamLogLevel = streamLogLevel;
132        return this;
133    }
134
135    /**
136     * Start the new process.
137     * 
138     * @return a new process
139     * @see ProcessBuilder#start()
140     */
141    public SimpleProcess<String> start() {
142        final Process process = startProcess();
143        final ProcessOutputConsumer<String> consumer = ProcessOutputConsumer.create(getExecutor(process), process,
144                streamCloseTimeout, streamLogLevel, new StringCollector(), new StringCollector());
145        consumer.start();
146        return new SimpleProcess<>(process, consumer, getCommand());
147    }
148
149    private Process startProcess() {
150        try {
151            return processBuilder.start();
152        } catch (final IOException exception) {
153            throw new UncheckedIOException(
154                    "Failed to start process %s in working dir %s: %s".formatted(processBuilder.command(),
155                            processBuilder.directory(), exception.getMessage()),
156                    exception);
157        }
158    }
159
160    private Executor getExecutor(final Process process) {
161        if (this.executor != null) {
162            return executor;
163        }
164        return createThreadExecutor(process.pid());
165    }
166
167    private static Executor createThreadExecutor(final long pid) {
168        return runnable -> {
169            final Thread thread = new Thread(runnable);
170            thread.setName("SimpleProcess-" + pid);
171            thread.setUncaughtExceptionHandler(new LoggingExceptionHandler());
172            thread.start();
173        };
174    }
175
176    private String getCommand() {
177        return processBuilder.command().stream().collect(joining(" "));
178    }
179
180    private static class LoggingExceptionHandler implements UncaughtExceptionHandler {
181        private static final Logger LOG = Logger.getLogger(LoggingExceptionHandler.class.getName());
182
183        @Override
184        public void uncaughtException(final Thread thread, final Throwable exception) {
185            LOG.log(Level.WARNING,
186                    "Exception occurred in thread '%s': %s".formatted(thread.getName(), exception.toString()),
187                    exception);
188        }
189    }
190}