public class Command {

	/**
	 * Command executable file, this executable will be run on operating system.
	 */
	private final String executable;

	/**
	 * Optional arguments (each arguments will be unique).
	 */
	private final List<CommandArg> arguments;

	/**
	 * Create new command providing executable path.
	 *
	 * @param executable Executable path.
	 */
	Command(String executable) {
		this.executable = executable;
		this.arguments = new LinkedList<>();
	}

	/**
	 * Add new argument to the command line.
	 *
	 * @param argument Argument.
	 */
	public void addArgument(CommandArg argument) {
		arguments.add(argument);
	}

	/**
	 * Add new argument to the command line.
	 *
	 * @param argument Argument.
	 */
	public void addArgument(String argument) {
		arguments.add(arg(argument));
	}

	/**
	 * Get executable path, can be used to execute command.
	 *
	 * @return Executable path.
	 */
	public String getExecutable() {
		return executable;
	}

	/**
	 * Get executable name.
	 *
	 * @return Executable name.
	 */
	public String getName() {
		String separator = "\\\\|/";
		String[] parts = executable.split(separator);
		return parts[parts.length - 1];
	}

	/**
	 * Get bin executable path, should not be used to execute command, as windows requires
	 * to use msdos wrapper.
	 *
	 * @return Bin path.
	 */
	public String getBin() {
		return executable;
	}

	/**
	 * Return unique list of arguments.
	 * Arguments are returned in the same order they were added.
	 *
	 * @return Arguments.
	 */
	public Collection<String> getArguments() {
		Set<String> args = new LinkedHashSet<>();
		for (CommandArg arg : arguments) {
			args.add(arg.toArgument());
		}

		return args;
	}

	@Override
	public String toString() {
		StringBuilder cmd = new StringBuilder();
		cmd.append(executable).append(" ");

		for (CommandArg arg : arguments) {
			cmd.append(arg.toString()).append(" ");
		}

		return cmd.toString().trim();
	}
} @SuppressWarnings("serial")
public class CommandException extends RuntimeException {

	/**
	 * Create new exception.
	 *
	 * @param ex Original IOException.
	 */
	public CommandException(IOException ex) {
		super(ex);
	}
} public interface CommandExecutor {

	/**
	 * Execute command line and return the result status.
	 *
	 * @param workingDirectory Working directory (i.e where the command line is executed).
	 * @param command Command, containing executable path with arguments.
	 * @param logger Logger to use to log command output.
	 * @param environment Environment variables.
	 * @return Command result object.
	 */
	CommandResult execute(File workingDirectory, Command command, OutputHandler logger, Map<String, String> environment);
} public final class CommandResult {

	/**
	 * Exit Status.
	 */
	private final int status;

	/**
	 * The command output.
	 */
	private final String out;

	/**
	 * Create new result object.
	 *
	 * @param status Status value.
	 * @param out Command output.
	 */
	public CommandResult(int status, String out) {
		this.status = status;
		this.out = firstNonNull(out, "");
	}

	/**
	 * Get the exit status.
	 *
	 * @return Exit status.
	 */
	public int getStatus() {
		return status;
	}

	/**
	 * Get {@link #out}
	 *
	 * @return {@link #out}
	 */
	public String getOut() {
		return out;
	}

	/**
	 * Check if result is a success: this is a shortcut for checking if {@link #status} is equal
	 * to zero.
	 *
	 * @return {@code true} if result is a success, {@code false} otherwise.
	 */
	public boolean isSuccess() {
		return status == 0;
	}

	/**
	 * Check if result is a failure: this is a shortcut for checking if {@link #status} is not equal
	 * to zero.
	 *
	 * @return {@code true} if result is a failure, {@code false} otherwise.
	 */
	public boolean isFailure() {
		return status != 0;
	}

	@Override
	public boolean equals(Object o) {
		if (o == this) {
			return true;
		}

		if (o instanceof CommandResult) {
			CommandResult r = (CommandResult) o;
			return Objects.equals(status, r.status) && Objects.equals(out, r.out);
		}

		return false;
	}

	@Override
	public int hashCode() {
		return Objects.hash(status, out);
	}

	@Override
	public String toString() {
		return ToStringBuilder.builder(getClass())
				.append("status", status)
				.append("out", out)
				.build();
	}
} public interface OutputHandler {

	/**
	 * The line to process.
	 *
	 * @param line The line.
	 */
	void process(String line);
} public class NpmLogger extends AbstractNpmLogger implements OutputHandler {

	/**
	 * Create new NPM logger using an existing maven logger.
	 *
	 * @param logger Maven logger.
	 * @return The NPM logger.
	 */
	public static NpmLogger npmLogger(Log logger) {
		return new NpmLogger(logger);
	}

	/**
	 * Maven logger.
	 */
	private final Log log;

	/**
	 * Create the NPM logger.
	 *
	 * @param log The maven logger.
	 */
	private NpmLogger(Log log) {
		this.log = log;
	}

	@Override
	void warn(String line) {
		log.warn(line);
	}

	@Override
	void error(String line) {
		log.error(line);
	}

	@Override
	void info(String line) {
		log.info(line);
	}
} public final class EngineConfig {

	/**
	 * If set to {@code true}, then {@code check} mojo will fail if require engine is not compatible with the current environment.
	 */
	private boolean strict;

	/**
	 * Versions of node/npm/any other npmClient that your stuff works on.
	 */
	private Map<String, String> requirements;

	public EngineConfig() {
		this.strict = false;
		this.requirements = new LinkedHashMap<>();
	}

	/**
	 * Create configuration instance.
	 *
	 * @param strict       {@link #strict}
	 * @param requirements {@link #requirements}
	 */
	public EngineConfig(boolean strict, Map<String, String> requirements) {
		this.strict = strict;
		this.requirements = new LinkedHashMap<>(requirements);
	}

	/**
	 * Get {@link #strict}
	 *
	 * @return {@link #strict}
	 */
	public boolean isStrict() {
		return strict;
	}

	/**
	 * Set {@link #strict}
	 *
	 * @param strict New {@link #strict}
	 */
	public void setStrict(boolean strict) {
		this.strict = strict;
	}

	/**
	 * Get {@link #requirements}
	 *
	 * @return {@link #requirements}
	 */
	public Map<String, String> getRequirements() {
		return unmodifiableMap(requirements);
	}

	/**
	 * New {@link #requirements}
	 *
	 * @param requirements New {@link #requirements}
	 */
	public void setRequirements(Map<String, String> requirements) {
		this.requirements = requirements;
	}

	/**
	 * Get the required setting for given engine.
	 *
	 * @param id Engine identifier (i.e {@code "node"}, {@code "npm"}, etc.).
	 * @return The engine configuration.
	 */
	public String getRequiredEngine(String id) {
		return requirements.get(id);
	}

	@Override
	public boolean equals(Object o) {
		if (o == this) {
			return true;
		}

		if (o instanceof EngineConfig) {
			EngineConfig e = (EngineConfig) o;
			return Objects.equals(strict, e.strict) && Objects.equals(requirements, e.requirements);
		}

		return false;
	}

	@Override
	public int hashCode() {
		return Objects.hash(strict, requirements);
	}

	@Override
	public String toString() {
		return ToStringBuilder.builder(getClass())
				.append("strict", strict)
				.append("requirements", requirements)
				.build();
	}
} public class EngineConfigTestBuilder {

	/**
	 * The strict flag.
	 *
	 * @see EngineConfig#isStrict()
	 */
	private boolean strict;

	/**
	 * The requirements.
	 *
	 * @see EngineConfig#getRequirements()
	 */
	private final Map<String, String> requirements;

	/**
	 * Create builder with default values.
	 */
	public EngineConfigTestBuilder() {
		this.strict = false;
		this.requirements = new LinkedHashMap<>();
	}

	/**
	 * Update {@link #strict}
	 *
	 * @param strict New {@link #strict}
	 * @return The builder.
	 */
	public EngineConfigTestBuilder withStrict(boolean strict) {
		this.strict = strict;
		return this;
	}

	/**
	 * Add new requirement.
	 *
	 * @param name Engine name.
	 * @param value Engine requirement (i.e version).
	 * @return The builder.
	 */
	public EngineConfigTestBuilder addRequirement(String name, String value) {
		this.requirements.put(name, value);
		return this;
	}

	/**
	 * Build final {@link EngineConfig} instance.
	 *
	 * @return The new instance.
	 */
	public EngineConfig build() {
		EngineConfig engineConfig = instantiate(EngineConfig.class);
		writePrivate(engineConfig, "strict", strict);
		writePrivate(engineConfig, "requirements", requirements);
		return engineConfig;
	}
} public static <T, U> Map<T, U> newMap(List<Map.Entry<T, U>> entries) {
	Map<T, U> map = new LinkedHashMap<>();

	for (Map.Entry<T, U> entry : entries) {
		map.put(entry.getKey(), entry.getValue());
	}

	return unmodifiableMap(map);
} public static <T, U> Map.Entry<T, U> newMapEntry(T key, U value) {
	return new DefaultMapEntry<>(key, value);
} @SuppressWarnings("unchecked")
public static <T> T readPrivate(Object instance, String name) {
	try {
		return (T) readField(instance, name, true);
	}
	catch (Exception ex) {
		throw new AssertionError(ex);
	}
} public static <T> void writePrivate(Object instance, String name, T value) {
	try {
		writeField(instance, name, value, true);
	}
	catch (Exception ex) {
		throw new AssertionError(ex);
	}
} public static CommandResult successResult() {
	return new CommandResultTestBuilder().withSuccess().build();
}
import com.github.mjeanroy.maven.plugins.node.commands.Command;
import com.github.mjeanroy.maven.plugins.node.commands.CommandException;
import com.github.mjeanroy.maven.plugins.node.commands.CommandExecutor;
import com.github.mjeanroy.maven.plugins.node.commands.CommandResult;
import com.github.mjeanroy.maven.plugins.node.commands.OutputHandler;
import com.github.mjeanroy.maven.plugins.node.loggers.NpmLogger;
import com.github.mjeanroy.maven.plugins.node.model.EngineConfig;
import com.github.mjeanroy.maven.plugins.node.tests.builders.EngineConfigTestBuilder;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.logging.Log;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.InOrder;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.Map;
import static com.github.mjeanroy.maven.plugins.node.tests.CollectionTestUtils.newMap;
import static com.github.mjeanroy.maven.plugins.node.tests.CollectionTestUtils.newMapEntry;
import static com.github.mjeanroy.maven.plugins.node.tests.ReflectTestUtils.readPrivate;
import static com.github.mjeanroy.maven.plugins.node.tests.ReflectTestUtils.writePrivate;
import static com.github.mjeanroy.maven.plugins.node.tests.builders.CommandResultTestBuilder.successResult;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
		EngineConfig engineConfig = new EngineConfigTestBuilder()
				.withStrict(false)
				.addRequirement("node", ">= 12")
				.addRequirement("npm", ">= 6")
				.addRequirement("yarn", "~1.20.0")
				.build();

		CheckNodeMojo mojo = givenMojo(newMap(asList(
				newMapEntry("npmClient", (Object) "yarn"),
				newMapEntry("executor", (Object) executor),
				newMapEntry("engines", (Object) engineConfig)
		)));

		mojo.execute();

		verify(readPrivate(mojo, "log", Log.class)).warn(
				expectedWarn
		);
	}

	private void check_fail_because_engine_requirement_failure(CommandExecutor executor, String expectedMessage) {
		EngineConfig engineConfig = new EngineConfigTestBuilder()
				.withStrict(true)
				.addRequirement("node", ">= 12")
				.addRequirement("npm", ">= 6")
				.addRequirement("yarn", "~1.20.0")
				.build();

		final CheckNodeMojo mojo = givenMojo(newMap(asList(
				newMapEntry("npmClient", (Object) "yarn"),
				newMapEntry("executor", (Object) executor),
				newMapEntry("engines", (Object) engineConfig)
		)));

		ThrowingCallable func = new ThrowingCallable() {
			@Override
			public void call() throws Throwable {
				mojo.execute();
			}
		};

		assertThatThrownBy(func).isInstanceOf(MojoExecutionException.class).hasMessage(expectedMessage);
	}

	private void check_warn_because_engine_requirement_in_package_json_failure(CommandExecutor executor, String expectedWarn) throws Exception {
		CheckNodeMojo mojo = givenMojo("mojo-with-engine", newMap(asList(
				newMapEntry("npmClient", (Object) "yarn"),
				newMapEntry("executor", (Object) executor)
		)));

		mojo.execute();

		verify(readPrivate(mojo, "log", Log.class)).warn(
				expectedWarn
		);
	}

	private void check_fail_because_engine_requirement_in_package_json_failure(CommandExecutor executor, String expectedMessage) {
		final CheckNodeMojo mojo = givenMojo("mojo-with-engine-strict", newMap(asList(
				newMapEntry("npmClient", (Object) "yarn"),
				newMapEntry("executor", (Object) executor)
		)));

		ThrowingCallable func = new ThrowingCallable() {
			@Override
			public void call() throws Throwable {
				mojo.execute();
			}
		};

		assertThatThrownBy(func).isInstanceOf(MojoExecutionException.class).hasMessage(expectedMessage);
	}

	private void verifyMojoExecution(CheckNodeMojo mojo, int numberOfCommand) {
		verify(readPrivate(mojo, "executor", CommandExecutor.class), times(numberOfCommand)).execute(
				any(File.class),
				any(Command.class),
				any(NpmLogger.class),
				ArgumentMatchers.<String, String>anyMap()
		);
	}

	private void verify_mojo_execution_exception(final CheckNodeMojo mojo, final String message) {
		ThrowingCallable mojoExecute = new ThrowingCallable() {
			@Override
			public void call() throws Throwable {
				mojo.execute();
			}
		};

		assertThatThrownBy(mojoExecute).isInstanceOf(MojoExecutionException.class).hasMessage(message);
	}

	private CheckNodeMojo givenMojo(String name, Map<String, ?> props) {
		CheckNodeMojo mojo = lookupEmptyMojo(name);
		for (Map.Entry<String, ?> prop : props.entrySet()) {
			writePrivate(mojo, prop.getKey(), prop.getValue());
		}

		return mojo;
	}

	private CheckNodeMojo givenMojo(Map<String, ?> props) {
		return givenMojo("mojo", props);
	}

	private static class CommandExecutionExceptionAnswer implements Answer<CommandResult> {
		private final String cmd;

		public CommandExecutionExceptionAnswer(String cmd) {
			this.cmd = cmd;
		}

		@Override
		public CommandResult answer(InvocationOnMock invocation) {
			Command command = (Command) invocation.getArguments()[1];
			if (command.toString().contains(cmd)) {
				throw new CommandException(new IOException());
			}

			return successResult();