diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f0a6bef..32068f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -84,7 +84,7 @@ Always manually validate jpm functionality after making changes: ### Important Files - `pom.xml` - Maven configuration with Spotless formatting, Shade plugin, Appassembler -- `app.yml` - Example dependency configuration (also created by jpm install) +- `app.yml` - jpm's actual runtime dependencies and actions (NOT an example file). Dependencies should be kept up-to-date with the (non-test) dependencies in pom.xml - `RELEASE.md` - Release process documentation - `.gitignore` - Excludes target/, deps/, IDE files diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ad9b8c..2e708da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,10 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v5 diff --git a/README.md b/README.md index b9e5392..fb1a4ec 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,76 @@ to get the required dependencies to run the code. _NB: We could have used `jpm copy` instead of `jpm install` to copy the dependencies but that would not have created the `app.yml` file._ +## Actions + +The `app.yml` file doesn't just track dependencies - it can also define custom actions that can be executed with the `jpm do` command or through convenient alias commands. + +### Defining Actions + +Actions are defined in the `actions` section of your `app.yml` file: + +```yaml +dependencies: + com.github.lalyos:jfiglet:0.0.9 + +actions: + build: "javac -cp {{deps}} *.java" + run: "java -cp {{deps}} HelloWorld" + test: "java -cp {{deps}} TestRunner" + clean: "rm -f *.class" +``` + +### Executing Actions + +You can execute actions using the `jpm do` command: + +```shell +$ jpm do build +$ jpm do run +$ jpm do --list # Lists all available actions +``` + +Or use the convenient alias commands: + +```shell +$ jpm build # Executes the 'build' action +$ jpm run # Executes the 'run' action +$ jpm test # Executes the 'test' action +$ jpm clean # Executes the 'clean' action +``` + +Alias commands can accept additional arguments that will be passed through to the underlying action: + +```shell +$ jpm run --verbose debug # Passes '--verbose debug' to the run action +``` + +### Variable Substitution + +Actions support several variable substitution features for cross-platform compatibility: + +- **`{{deps}}`** - Replaced with the full classpath of all dependencies +- **`{/}`** - Replaced with the file separator (`\` on Windows, `/` on Linux/Mac) +- **`{:}`** - Replaced with the path separator (`;` on Windows, `:` on Linux/Mac) +- **`{~}`** - Replaced with the user's home directory (The actual path on Windows, `~` on Linux/Mac) +- **`{./path/to/file}`** - Converts relative paths to platform-specific format +- **`{./libs:./ext:~/usrlibs}`** - Converts entire class paths to platform-specific format + +Example with cross-platform compatibility: + +```yaml +actions: + build: "javac -cp {{deps}} -d {./target/classes} src{/}*.java" + run: "java -cp {{deps}}{:}{./target/classes} Main" + test: "java -cp {{deps}}{:}{./target/classes} org.junit.runner.JUnitCore TestSuite" +``` + +NB: The `{{deps}}` variable substitution is only performed when needed - if your action doesn't contain `{{deps}}`, jpm won't resolve the classpath, making execution faster for simple actions that don't require dependencies. + +NB2: These actions are just a very simple convenience feature. For a much more full-featured cross-platform action runner I recommend taking a look at: + + - [Just](https://github.com/casey/just) - Just a command runner + ## Installation For now the simplest way to install `jpm` is to use [JBang](https://www.jbang.dev/download/): @@ -132,8 +202,8 @@ Commands: dependencies to the target directory while at the same time removing any artifacts that are no longer needed (ie the ones that are not mentioned in the app.yml file). If no artifacts - are passed the app.yml file will be left untouched and only - the existing dependencies in the file will be copied. + are passed the app.yml file will be left untouched and only the + existing dependencies in the file will be copied. Example: jpm install org.apache.httpcomponents:httpclient:4.5.14 @@ -145,6 +215,18 @@ Commands: Example: jpm path org.apache.httpcomponents:httpclient:4.5.14 + + do Executes an action command defined in the app.yml file. Actions + can use variable substitution for classpath. + + Example: + jpm do build + jpm do test + + clean Executes the 'clean' action as defined in the app.yml file. + build Executes the 'build' action as defined in the app.yml file. + run Executes the 'run' action as defined in the app.yml file. + test Executes the 'test' action as defined in the app.yml file. ``` ## Development diff --git a/app.yml b/app.yml index 8bd8a9f..94a6610 100644 --- a/app.yml +++ b/app.yml @@ -1,10 +1,15 @@ dependencies: - eu.maveniverse.maven.mima:context: "2.4.15" - eu.maveniverse.maven.mima.runtime:standalone-static: "2.4.15" - info.picocli:picocli: "4.7.6" - org.yaml:snakeyaml: "2.3" - org.jline:jline-console-ui: "3.29.0" - org.jline:jline-terminal-jni: "3.29.0" - org.slf4j:slf4j-api: "2.0.13" - org.slf4j:slf4j-log4j12: "2.0.13" - org.slf4j:slf4j-simple: "2.0.13" + eu.maveniverse.maven.mima:context: "2.4.33" + eu.maveniverse.maven.mima.runtime:standalone-static: "2.4.33" + info.picocli:picocli: "4.7.7" + org.yaml:snakeyaml: "2.4" + org.jline:jline-console-ui: "3.30.5" + org.jline:jline-terminal-jni: "3.30.5" + org.slf4j:slf4j-api: "2.0.17" + org.slf4j:slf4j-simple: "2.0.17" + +actions: + clean: ".{/}mvnw clean" + build: ".{/}mvnw spotless:apply package -DskipTests" + run: "{./target/binary/bin/jpm}" + test: ".{/}mvnw test" diff --git a/pom.xml b/pom.xml index 0ddfe16..b543d86 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,9 @@ 2.0.17 2.46.1 1.22.0 + 5.11.4 + 5.15.2 + 3.26.3 @@ -82,6 +85,32 @@ slf4j-simple ${version.slf4j} + + + + org.junit.jupiter + junit-jupiter + ${version.junit} + test + + + org.mockito + mockito-core + ${version.mockito} + test + + + org.mockito + mockito-junit-jupiter + ${version.mockito} + test + + + org.assertj + assertj-core + ${version.assertj} + test + @@ -97,6 +126,11 @@ maven-compiler-plugin 3.14.0 + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + org.apache.maven.plugins maven-jar-plugin diff --git a/src/main/java/org/codejive/jpm/Jpm.java b/src/main/java/org/codejive/jpm/Jpm.java index 52622a7..f2ce420 100644 --- a/src/main/java/org/codejive/jpm/Jpm.java +++ b/src/main/java/org/codejive/jpm/Jpm.java @@ -12,10 +12,12 @@ public class Jpm { private final Path directory; private final boolean noLinks; + private final boolean verbose; - private Jpm(Path directory, boolean noLinks) { + private Jpm(Path directory, boolean noLinks, boolean verbose) { this.directory = directory; this.noLinks = noLinks; + this.verbose = verbose; } /** @@ -31,6 +33,7 @@ public static Builder builder() { public static class Builder { private Path directory; private boolean noLinks; + private boolean verbose; private Builder() {} @@ -56,13 +59,24 @@ public Builder noLinks(boolean noLinks) { return this; } + /** + * Set whether to enable verbose output or not. + * + * @param verbose Whether to enable verbose output or not. + * @return The builder instance for chaining. + */ + public Builder verbose(boolean verbose) { + this.verbose = verbose; + return this; + } + /** * Builds the {@link Jpm} instance. * * @return A {@link Jpm} instance. */ public Jpm build() { - return new Jpm(directory, noLinks); + return new Jpm(directory, noLinks, verbose); } } @@ -167,6 +181,49 @@ private static String[] getArtifacts(String[] artifactNames, AppInfo appInfo) { return deps; } + /** + * Executes an action defined in app.yml file. + * + * @param actionName The name of the action to execute (null to list actions) + * @return An integer containing the exit result of the action + * @throws IllegalArgumentException If the action name is not provided or not found + * @throws IOException If an error occurred during the operation + * @throws DependencyResolutionException If an error occurred during dependency resolution + * @throws InterruptedException If the action execution was interrupted + */ + public int executeAction(String actionName, List args) + throws IOException, DependencyResolutionException, InterruptedException { + AppInfo appInfo = AppInfo.read(); + + // Get the action command + String command = appInfo.getAction(actionName); + if (command == null) { + throw new IllegalArgumentException( + "Action '" + + actionName + + "' not found in app.yml. Use --list to see available actions."); + } + + // Get the classpath for variable substitution only if needed + List classpath = Collections.emptyList(); + if (command.contains("{{deps}}")) { + classpath = this.path(new String[0]); // Empty array means use dependencies from app.yml + } + + return ScriptUtils.executeScript(command, args, classpath, true); + } + + /** + * Returns a list of available action names defined in the app.yml file. + * + * @return A list of available action names + * @throws IOException If an error occurred during the operation + */ + public List listActions() throws IOException { + AppInfo appInfo = AppInfo.read(); + return new ArrayList<>(appInfo.getActionNames()); + } + private static boolean isWindows() { String os = System.getProperty("os.name") diff --git a/src/main/java/org/codejive/jpm/Main.java b/src/main/java/org/codejive/jpm/Main.java index 2111b51..07bd69d 100644 --- a/src/main/java/org/codejive/jpm/Main.java +++ b/src/main/java/org/codejive/jpm/Main.java @@ -34,6 +34,7 @@ import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; +import picocli.CommandLine.Unmatched; /** Main class for the jpm command line tool. */ @Command( @@ -45,7 +46,12 @@ Main.Copy.class, Main.Search.class, Main.Install.class, - Main.PrintPath.class + Main.PrintPath.class, + Main.Do.class, + Main.Clean.class, + Main.Build.class, + Main.Run.class, + Main.Test.class }) public class Main { @@ -71,8 +77,8 @@ static class Copy implements Callable { public Integer call() throws Exception { SyncStats stats = Jpm.builder() - .directory(artifactsMixin.copyMixin.directory) - .noLinks(artifactsMixin.copyMixin.noLinks) + .directory(artifactsMixin.depsMixin.directory) + .noLinks(artifactsMixin.depsMixin.noLinks) .build() .copy(artifactsMixin.artifactNames, sync); if (!quietMixin.quiet) { @@ -93,7 +99,7 @@ public Integer call() throws Exception { + "Example:\n jpm search httpclient\n") static class Search implements Callable { @Mixin QuietMixin quietMixin; - @Mixin CopyMixin copyMixin; + @Mixin DepsMixin depsMixin; @Option( names = {"-i", "--interactive"}, @@ -132,8 +138,8 @@ public Integer call() throws Exception { if ("install".equals(artifactAction)) { SyncStats stats = Jpm.builder() - .directory(copyMixin.directory) - .noLinks(copyMixin.noLinks) + .directory(depsMixin.directory) + .noLinks(depsMixin.noLinks) .build() .install(new String[] {selectedArtifact}); if (!quietMixin.quiet) { @@ -142,8 +148,8 @@ public Integer call() throws Exception { } else if ("copy".equals(artifactAction)) { SyncStats stats = Jpm.builder() - .directory(copyMixin.directory) - .noLinks(copyMixin.noLinks) + .directory(depsMixin.directory) + .noLinks(depsMixin.noLinks) .build() .copy(new String[] {selectedArtifact}, false); if (!quietMixin.quiet) { @@ -174,8 +180,8 @@ public Integer call() throws Exception { String[] search(String artifactPattern) { try { return Jpm.builder() - .directory(copyMixin.directory) - .noLinks(copyMixin.noLinks) + .directory(depsMixin.directory) + .noLinks(depsMixin.noLinks) .build() .search(artifactPattern, Math.min(max, 200)); } catch (IOException e) { @@ -271,8 +277,8 @@ static class Install implements Callable { public Integer call() throws Exception { SyncStats stats = Jpm.builder() - .directory(optionalArtifactsMixin.copyMixin.directory) - .noLinks(optionalArtifactsMixin.copyMixin.noLinks) + .directory(optionalArtifactsMixin.depsMixin.directory) + .noLinks(optionalArtifactsMixin.depsMixin.noLinks) .build() .install(optionalArtifactsMixin.artifactNames); if (!quietMixin.quiet) { @@ -296,8 +302,8 @@ static class PrintPath implements Callable { public Integer call() throws Exception { List files = Jpm.builder() - .directory(optionalArtifactsMixin.copyMixin.directory) - .noLinks(optionalArtifactsMixin.copyMixin.noLinks) + .directory(optionalArtifactsMixin.depsMixin.directory) + .noLinks(optionalArtifactsMixin.depsMixin.noLinks) .build() .path(optionalArtifactsMixin.artifactNames); if (!files.isEmpty()) { @@ -311,7 +317,168 @@ public Integer call() throws Exception { } } - static class CopyMixin { + @Command( + name = "do", + description = + "Executes an action command defined in the app.yml file.\n\n" + + "Example:\n jpm do build\n jpm do test --arg verbose\n") + static class Do implements Callable { + @Mixin DepsMixin depsMixin; + @Mixin QuietMixin quietMixin; + + @Option( + names = {"-l", "--list"}, + description = "List all available actions", + defaultValue = "false") + private boolean list; + + @Parameters( + paramLabel = "action", + description = "Name of the action to execute as defined in app.yml", + arity = "0..*", + index = "0") + private String actionName; + + @Parameters( + paramLabel = "actionsAndArguments", + description = + "Optional additional actions and/or arguments to be passed to the action(s)", + arity = "0..*", + index = "1..*") + private ArrayList actsAndArgs = new ArrayList<>(); + + @Override + public Integer call() throws Exception { + try { + if (list) { + List actionNames = + Jpm.builder() + .directory(depsMixin.directory) + .noLinks(depsMixin.noLinks) + .build() + .listActions(); + if (actionNames.isEmpty()) { + if (!quietMixin.quiet) System.out.println("No actions defined in app.yml"); + } else { + if (!quietMixin.quiet) System.out.println("Available actions:"); + actionNames.forEach(n -> System.out.println(" " + n)); + } + } else { + if (actionName == null || actionName.isEmpty()) { + System.err.println( + "Action name is required. Use --list to see available actions."); + return 1; + } + // Split the full arguments list in multiple actions and their arguments + int idx = 0; + actsAndArgs.add(0, actionName); + while (idx < actsAndArgs.size()) { + String action = actsAndArgs.get(idx); + if (action.startsWith("-")) { + System.err.println( + "Unexpected argument, was expecting an action name: " + action); + return 1; + } + idx++; + List args = new ArrayList<>(); + while (idx < actsAndArgs.size() && actsAndArgs.get(idx).startsWith("-")) { + String opt = actsAndArgs.get(idx); + if (opt.equals("-a") || opt.equals("--arg")) { + args.add(actsAndArgs.get(++idx)); + } else if (opt.startsWith("-a=") || opt.startsWith("--arg=")) { + args.add(opt.substring(opt.indexOf('=') + 1)); + } else { + System.err.println( + "Unexpected argument, was expecting an action argument like '-a' or '--arg', not: " + + opt); + return 1; + } + idx++; + } + int exitCode = + Jpm.builder() + .directory(depsMixin.directory) + .noLinks(depsMixin.noLinks) + .verbose(!quietMixin.quiet) + .build() + .executeAction(action, args); + if (exitCode != 0) { + return exitCode; + } + } + } + } catch (Exception e) { + System.err.println(e.getMessage()); + return 1; + } + return 0; + } + } + + abstract static class DoAlias implements Callable { + @Mixin DepsMixin depsMixin; + + @Unmatched List args = new ArrayList<>(); + + abstract String actionName(); + + @Override + public Integer call() throws Exception { + try { + // Use only unmatched args for pass-through to preserve ordering + return Jpm.builder() + .directory(depsMixin.directory) + .noLinks(depsMixin.noLinks) + .build() + .executeAction(actionName(), args); + } catch (Exception e) { + System.err.println(e.getMessage()); + return 1; + } + } + } + + @Command( + name = "clean", + description = "Executes the 'clean' action as defined in the app.yml file.") + static class Clean extends DoAlias { + @Override + String actionName() { + return "clean"; + } + } + + @Command( + name = "build", + description = "Executes the 'build' action as defined in the app.yml file.") + static class Build extends DoAlias { + @Override + String actionName() { + return "build"; + } + } + + @Command( + name = "run", + description = "Executes the 'run' action as defined in the app.yml file.") + static class Run extends DoAlias { + @Override + String actionName() { + return "run"; + } + } + + @Command( + name = "test", + description = "Executes the 'test' action as defined in the app.yml file.") + static class Test extends DoAlias { + @Override + String actionName() { + return "test"; + } + } + + static class DepsMixin { @Option( names = {"-d", "--directory"}, description = "Directory to copy artifacts to", @@ -326,7 +493,7 @@ static class CopyMixin { } static class ArtifactsMixin { - @Mixin CopyMixin copyMixin; + @Mixin DepsMixin depsMixin; @Parameters( paramLabel = "artifacts", @@ -337,7 +504,7 @@ static class ArtifactsMixin { } static class OptionalArtifactsMixin { - @Mixin CopyMixin copyMixin; + @Mixin DepsMixin depsMixin; @Parameters( paramLabel = "artifacts", @@ -361,17 +528,19 @@ private static void printStats(SyncStats stats) { (Integer) stats.copied, (Integer) stats.updated, (Integer) stats.deleted); } + public static CommandLine getCommandLine() { + return new CommandLine(new Main()) + .setStopAtPositional(true) + .setAllowOptionsAsOptionParameters(true) + .setAllowSubcommandsAsOptionParameters(true); + } + /** * Main entry point for the jpm command line tool. * * @param args The command line arguments. */ public static void main(String... args) { - if (args.length == 0) { - System.err.println( - "Running 'jpm search --interactive', try 'jpm --help' for more options"); - args = new String[] {"search", "--interactive"}; - } - new CommandLine(new Main()).execute(args); + getCommandLine().execute(args); } } diff --git a/src/main/java/org/codejive/jpm/json/AppInfo.java b/src/main/java/org/codejive/jpm/json/AppInfo.java index 8c52db4..83ccbfe 100644 --- a/src/main/java/org/codejive/jpm/json/AppInfo.java +++ b/src/main/java/org/codejive/jpm/json/AppInfo.java @@ -18,6 +18,7 @@ public class AppInfo { private Map yaml = new TreeMap<>(); public Map dependencies = new TreeMap<>(); + public Map actions = new TreeMap<>(); /** The official name of the app.yml file. */ public static final String APP_INFO_FILE = "app.yml"; @@ -33,6 +34,25 @@ public String[] getDependencyGAVs() { .toArray(String[]::new); } + /** + * Returns the action command for the given action name. + * + * @param actionName The name of the action + * @return The action command or null if not found + */ + public String getAction(String actionName) { + return actions.get(actionName); + } + + /** + * Returns all available action names. + * + * @return A set of action names + */ + public java.util.Set getActionNames() { + return actions.keySet(); + } + /** * Reads the app.yml file in the current directory and returns its content as an AppInfo object. * @@ -40,15 +60,17 @@ public String[] getDependencyGAVs() { * @throws IOException if an error occurred while reading or parsing the file */ public static AppInfo read() throws IOException { - Path prjJson = Paths.get(APP_INFO_FILE); + Path prjJson = Paths.get(System.getProperty("user.dir"), APP_INFO_FILE); AppInfo appInfo = new AppInfo(); if (Files.isRegularFile(prjJson)) { try (Reader in = Files.newBufferedReader(prjJson)) { Yaml yaml = new Yaml(); appInfo.yaml = yaml.load(in); } - } else { - appInfo = new AppInfo(); + } + // Ensure yaml is never null + if (appInfo.yaml == null) { + appInfo.yaml = new TreeMap<>(); } // WARNING awful code ahead if (appInfo.yaml.containsKey("dependencies") @@ -58,6 +80,13 @@ public static AppInfo read() throws IOException { appInfo.dependencies.put(entry.getKey(), entry.getValue().toString()); } } + // Parse actions section + if (appInfo.yaml.containsKey("actions") && appInfo.yaml.get("actions") instanceof Map) { + Map actions = (Map) appInfo.yaml.get("actions"); + for (Map.Entry entry : actions.entrySet()) { + appInfo.actions.put(entry.getKey(), entry.getValue().toString()); + } + } return appInfo; } @@ -68,7 +97,7 @@ public static AppInfo read() throws IOException { * @throws IOException if an error occurred while writing the file */ public static void write(AppInfo appInfo) throws IOException { - Path prjJson = Paths.get(APP_INFO_FILE); + Path prjJson = Paths.get(System.getProperty("user.dir"), APP_INFO_FILE); try (Writer out = Files.newBufferedWriter(prjJson)) { DumperOptions dopts = new DumperOptions(); dopts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); @@ -76,6 +105,7 @@ public static void write(AppInfo appInfo) throws IOException { Yaml yaml = new Yaml(dopts); // WARNING awful code ahead appInfo.yaml.put("dependencies", (Map) (Map) appInfo.dependencies); + appInfo.yaml.put("actions", (Map) (Map) appInfo.actions); yaml.dump(appInfo.yaml, out); } } diff --git a/src/main/java/org/codejive/jpm/util/ScriptUtils.java b/src/main/java/org/codejive/jpm/util/ScriptUtils.java new file mode 100644 index 0000000..54ef5b0 --- /dev/null +++ b/src/main/java/org/codejive/jpm/util/ScriptUtils.java @@ -0,0 +1,131 @@ +package org.codejive.jpm.util; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +/** Utility class for executing scripts with path conversion and variable substitution. */ +public class ScriptUtils { + + /** + * Executes a script command with variable substitution and path conversion. + * + * @param command The command to execute + * @param args The arguments to pass to the command + * @param classpath The classpath to use for {{deps}} substitution + * @param verbose If true, prints the command before execution + * @return The exit code of the executed command + * @throws IOException if an error occurred during execution + * @throws InterruptedException if the execution was interrupted + */ + public static int executeScript( + String command, List args, List classpath, boolean verbose) + throws IOException, InterruptedException { + if (args != null && !args.isEmpty()) { + command += + args.stream() + .map(ScriptUtils::quoteArgument) + .collect(Collectors.joining(" ", " ", "")); + } + + String processedCommand = processCommand(command, classpath); + if (verbose) { + System.out.println("> " + processedCommand); + } + String[] commandTokens = + isWindows() + ? new String[] {"cmd.exe", "/c", processedCommand} + : new String[] {"/bin/sh", "-c", processedCommand}; + ProcessBuilder pb = new ProcessBuilder(commandTokens); + pb.redirectErrorStream(true); + Process p = pb.start(); + BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream())); + br.lines().forEach(System.out::println); + return p.waitFor(); + } + + static String quoteArgument(String arg) { + return arg; + } + + /** + * Processes a command by performing variable substitution and path conversion. + * + * @param command The raw command + * @param classpath The classpath to use for {{deps}} substitution + * @return The processed command + */ + static String processCommand(String command, List classpath) { + String result = command; + + // Substitute {{deps}} with the classpath + if (result.contains("{{deps}}")) { + String classpathStr = ""; + if (classpath != null && !classpath.isEmpty()) { + classpathStr = + classpath.stream() + .map(Path::toString) + .collect(Collectors.joining(File.pathSeparator)); + } + result = result.replace("{{deps}}", classpathStr); + } + + // Find all occurrences of {./...} and {~/...} and replace them with os paths + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\{([.~]/[^}]*)}"); + java.util.regex.Matcher matcher = pattern.matcher(result); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + String path = matcher.group(1); + String replacedPath; + if (isWindows()) { + String[] cp = path.split(":"); + replacedPath = + Arrays.stream(cp) + .map( + p -> { + if (p.startsWith("~/")) { + return Paths.get( + System.getProperty("user.home"), + p.substring(2)) + .toString(); + } else { + return Paths.get(p).toString(); + } + }) + .collect(Collectors.joining(File.pathSeparator)); + replacedPath = replacedPath.replace("\\", "\\\\"); + } else { + // If we're not on Windows, we assume the path is already correct + replacedPath = path; + } + matcher.appendReplacement(sb, replacedPath); + } + matcher.appendTail(sb); + result = sb.toString(); + + result = result.replace("{/}", File.separator); + result = result.replace("{:}", File.pathSeparator); + result = + result.replace( + "{~}", + isWindows() ? Paths.get(System.getProperty("user.home")).toString() : "~"); + + return result; + } + + /** Checks if the current operating system is Windows. */ + public static boolean isWindows() { + String os = + System.getProperty("os.name") + .toLowerCase(Locale.ENGLISH) + .replaceAll("[^a-z0-9]+", ""); + return os.startsWith("win"); + } +} diff --git a/src/test/java/org/codejive/jpm/DoCommandPerformanceTest.java b/src/test/java/org/codejive/jpm/DoCommandPerformanceTest.java new file mode 100644 index 0000000..39fbe8c --- /dev/null +++ b/src/test/java/org/codejive/jpm/DoCommandPerformanceTest.java @@ -0,0 +1,141 @@ +package org.codejive.jpm; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import org.codejive.jpm.util.ScriptUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import picocli.CommandLine; + +/** + * Test class specifically for verifying performance optimization in the 'do' command. This test + * verifies that classpath resolution only happens when {{deps}} is present. + */ +class DoCommandPerformanceTest { + + @TempDir Path tempDir; + + private String originalDir; + + @BeforeEach + void setUp() { + originalDir = System.getProperty("user.dir"); + System.setProperty("user.dir", tempDir.toString()); + } + + @AfterEach + void tearDown() { + System.setProperty("user.dir", originalDir); + } + + @Test + void testPerformanceOptimizationNoDepsVariable() throws IOException { + // Create app.yml with action that doesn't use {{deps}} + createAppYmlWithSimpleAction(); + + // Mock ScriptUtils to verify classpath is empty when no {{deps}} variable + try (MockedStatic mockedScriptUtils = Mockito.mockStatic(ScriptUtils.class)) { + mockedScriptUtils + .when( + () -> + ScriptUtils.executeScript( + anyString(), any(List.class), any(), anyBoolean())) + .thenReturn(0); + + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("do", "simple"); + + assertThat(exitCode).isEqualTo(0); + + // Verify that executeScript was called with empty classpath + mockedScriptUtils.verify( + () -> + ScriptUtils.executeScript( + eq("true"), + eq(Collections.emptyList()), + eq(Collections.emptyList()), + eq(true)), + times(1)); + } + } + + @Test + void testCaseInsensitiveDepsVariable() throws IOException { + // Test that only exact {{deps}} triggers optimization, not variations + String yamlContent = + "actions:\n" + + " exact: \"java -cp {{deps}} MainClass\"\n" + + " different: \"java -cp ${DEPS} MainClass\"\n" + + " substring: \"java -cp mydeps MainClass\"\n"; + Files.writeString(tempDir.resolve("app.yml"), yamlContent); + + try (MockedStatic mockedScriptUtils = Mockito.mockStatic(ScriptUtils.class)) { + mockedScriptUtils + .when( + () -> + ScriptUtils.executeScript( + anyString(), any(List.class), any(), anyBoolean())) + .thenReturn(0); + + CommandLine cmd = Main.getCommandLine(); + + // Test exact match - should resolve classpath + cmd.execute("do", "exact"); + mockedScriptUtils.verify( + () -> + ScriptUtils.executeScript( + contains("{{deps}}"), + eq(Collections.emptyList()), + any(), + eq(true)), + times(1)); + + mockedScriptUtils.clearInvocations(); + + // Test different case - should NOT resolve classpath + cmd.execute("do", "different"); + mockedScriptUtils.verify( + () -> + ScriptUtils.executeScript( + eq("java -cp ${DEPS} MainClass"), + eq(Collections.emptyList()), + eq(Collections.emptyList()), + eq(true)), + times(1)); + + mockedScriptUtils.clearInvocations(); + + // Test substring - should NOT resolve classpath + cmd.execute("do", "substring"); + mockedScriptUtils.verify( + () -> + ScriptUtils.executeScript( + eq("java -cp mydeps MainClass"), + eq(Collections.emptyList()), + eq(Collections.emptyList()), + eq(true)), + times(1)); + } + } + + private void createAppYmlWithSimpleAction() throws IOException { + String yamlContent = + "dependencies:\n" + + " com.github.lalyos:jfiglet: \"0.0.9\"\n" + + "\n" + + "actions:\n" + + " simple: \"true\"\n"; + Files.writeString(tempDir.resolve("app.yml"), yamlContent); + } +} diff --git a/src/test/java/org/codejive/jpm/MainIntegrationTest.java b/src/test/java/org/codejive/jpm/MainIntegrationTest.java new file mode 100644 index 0000000..f04e101 --- /dev/null +++ b/src/test/java/org/codejive/jpm/MainIntegrationTest.java @@ -0,0 +1,290 @@ +package org.codejive.jpm; + +import static org.assertj.core.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +/** Integration tests for the Main class, focusing on the new 'do' command and aliases. */ +class MainIntegrationTest { + + @TempDir Path tempDir; + + private String originalDir; + + @BeforeEach + void setUp() { + originalDir = System.getProperty("user.dir"); + System.setProperty("user.dir", tempDir.toString()); + } + + @AfterEach + void tearDown() { + System.setProperty("user.dir", originalDir); + } + + /** + * Helper method to capture stdout/stderr for tests that need to check command output. Only use + * this for tests that check jpm's own output, not for tests that execute system commands. + */ + private TestOutputCapture captureOutput() { + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setErr(new PrintStream(errContent)); + + return new TestOutputCapture(originalOut, originalErr, outContent, errContent); + } + + /** Helper class to manage output capture and restoration */ + private static class TestOutputCapture implements AutoCloseable { + private final PrintStream originalOut; + private final PrintStream originalErr; + private final ByteArrayOutputStream outContent; + private final ByteArrayOutputStream errContent; + + TestOutputCapture( + PrintStream originalOut, + PrintStream originalErr, + ByteArrayOutputStream outContent, + ByteArrayOutputStream errContent) { + this.originalOut = originalOut; + this.originalErr = originalErr; + this.outContent = outContent; + this.errContent = errContent; + } + + String getOut() { + return outContent.toString(); + } + + String getErr() { + return errContent.toString(); + } + + @Override + public void close() { + System.setOut(originalOut); + System.setErr(originalErr); + } + } + + @Test + void testDoCommandList() throws IOException { + // Create app.yml with actions + createAppYml(); + + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("do", "--list"); + + assertThat(exitCode).isEqualTo(0); + String output = capture.getOut(); + assertThat(output).contains("Available actions:", "build", "test", "run", "hello"); + } + } + + @Test + void testDoCommandListShortFlag() throws IOException { + // Create app.yml with actions + createAppYml(); + + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("do", "-l"); + + assertThat(exitCode).isEqualTo(0); + String output = capture.getOut(); + assertThat(output).contains("Available actions:"); + } + } + + @Test + void testDoCommandListNoActions() throws IOException { + // Create app.yml without actions + createAppYmlWithoutActions(); + + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("do", "--list"); + + assertThat(exitCode).isEqualTo(0); + String output = capture.getOut(); + assertThat(output).contains("No actions defined in app.yml"); + } + } + + @Test + void testDoCommandListNoAppYml() { + // No app.yml file exists + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("do", "--list"); + + assertThat(exitCode).isEqualTo(0); + String output = capture.getOut(); + assertThat(output).contains("No actions defined in app.yml"); + } + } + + @Test + void testDoCommandMissingActionName() throws IOException { + createAppYml(); + + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("do"); + + assertThat(exitCode).isEqualTo(1); + String errorOutput = capture.getErr(); + assertThat(errorOutput) + .contains("Action name is required", "Use --list to see available actions"); + } + } + + @Test + void testDoCommandNonexistentAction() throws IOException { + createAppYml(); + + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("do", "nonexistent"); + + assertThat(exitCode).isEqualTo(1); + String errorOutput = capture.getErr(); + assertThat(errorOutput) + .contains( + "Action 'nonexistent' not found in app.yml. Use --list to see available actions."); + } + } + + @Test + void testBuildAlias() throws IOException { + createAppYml(); + + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("build"); + + // Test that build alias works (delegates to 'do build') + assertThat(exitCode >= 0).isTrue(); + } + + @Test + void testTestAlias() throws IOException { + createAppYml(); + + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("test"); + + // Test that test alias works (delegates to 'do test') + assertThat(exitCode >= 0).isTrue(); + } + + @Test + void testRunAlias() throws IOException { + createAppYml(); + + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("run"); + + // Test that run alias works (delegates to 'do run') + assertThat(exitCode >= 0).isTrue(); + } + + @Test + void testAliasWithNonexistentAction() throws IOException { + // Create app.yml without 'build' action + createAppYmlWithoutBuildAction(); + + // Test the "do" command directly (which is what the alias redirects to) + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("do", "build"); + + // Should fail with exit code 1 when action is not found + assertThat(exitCode).isEqualTo(1); + String errorOutput = capture.getErr(); + assertThat(errorOutput).contains("Action 'build' not found in app.yml"); + } + } + + @Test + void testDoWithOutput() throws IOException { + // Create app.yml with action that doesn't use {{deps}} + createAppYml(); + + CommandLine cmd = Main.getCommandLine(); + + try (TestOutputCapture capture = captureOutput()) { + int exitCode = cmd.execute("do", "hello"); + assertThat(exitCode >= 0).isTrue(); // Should not be negative (internal error) + String output = capture.getOut(); + assertThat(output).contains("Hello World"); + } + } + + @Test + void testMainWithNoArgs() { + // Test the default behavior using CommandLine + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute(); + + // Should show help when no args provided (CommandLine default behavior) + // The Main.main() method redirects to interactive search, but CommandLine.execute() + // with no args typically shows help + assertThat(exitCode >= 0).isTrue(); // Should not be negative (internal error) + } + + @Test + void testDoAliasWithArgs() throws IOException { + createAppYml(); + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("run", "--foo", "bar"); + + assertThat(exitCode).isEqualTo(0); + String output = capture.getOut(); + // The run action should execute and include the classpath in the output + assertThat(output).contains("running... .", "libs", "--foo bar"); + } + } + + private void createAppYml() throws IOException { + String yamlContent = + "dependencies:\n" + + " com.github.lalyos:jfiglet: \"0.0.9\"\n" + + "\n" + + "actions:\n" + + " build: \"echo building... .{/}libs{:}{{deps}}\"\n" + + " test: \"echo testing... .{/}libs{:}{{deps}}\"\n" + + " run: \"echo running... .{/}libs{:}{{deps}}\"\n" + + " hello: \"echo Hello World\"\n"; + Files.writeString(tempDir.resolve("app.yml"), yamlContent); + } + + private void createAppYmlWithoutActions() throws IOException { + String yamlContent = "dependencies:\n" + " com.github.lalyos:jfiglet: \"0.0.9\"\n"; + Files.writeString(tempDir.resolve("app.yml"), yamlContent); + } + + private void createAppYmlWithoutBuildAction() throws IOException { + String yamlContent = + "dependencies:\n" + + " com.github.lalyos:jfiglet: \"0.0.9\"\n" + + "\n" + + "actions:\n" + + " test: \"java -cp {{deps}} TestRunner\"\n" + + " hello: \"echo Hello World\"\n"; + Files.writeString(tempDir.resolve("app.yml"), yamlContent); + } +} diff --git a/src/test/java/org/codejive/jpm/json/AppInfoTest.java b/src/test/java/org/codejive/jpm/json/AppInfoTest.java new file mode 100644 index 0000000..d269112 --- /dev/null +++ b/src/test/java/org/codejive/jpm/json/AppInfoTest.java @@ -0,0 +1,162 @@ +package org.codejive.jpm.json; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Tests for AppInfo class, focusing on action parsing and management. */ +class AppInfoTest { + + @TempDir Path tempDir; + + @Test + void testReadAppInfoWithActions() throws IOException { + // Create a test app.yml file with actions + Path appYmlPath = tempDir.resolve("app.yml"); + String yamlContent = + "dependencies:\n" + + " com.example:test-lib: \"1.0.0\"\n" + + "\n" + + "actions:\n" + + " build: \"javac -cp {{deps}} *.java\"\n" + + " test: \"java -cp {{deps}} TestRunner\"\n" + + " run: \"java -cp .:{{deps}} MainClass\"\n" + + " hello: \"echo Hello World\"\n"; + Files.writeString(appYmlPath, yamlContent); + + // Change to temp directory for reading + String originalDir = System.getProperty("user.dir"); + System.setProperty("user.dir", tempDir.toString()); + + try { + AppInfo appInfo = AppInfo.read(); + + // Test action retrieval + assertThat(appInfo.getAction("build")).isEqualTo("javac -cp {{deps}} *.java"); + assertThat(appInfo.getAction("test")).isEqualTo("java -cp {{deps}} TestRunner"); + assertThat(appInfo.getAction("run")).isEqualTo("java -cp .:{{deps}} MainClass"); + assertThat(appInfo.getAction("hello")).isEqualTo("echo Hello World"); + + // Test action names + Set actionNames = appInfo.getActionNames(); + assertThat(actionNames).hasSize(4); + assertThat(actionNames).contains("build", "test", "run", "hello"); + + // Test non-existent action + assertThat(appInfo.getAction("nonexistent")).isNull(); + + // Test dependencies are still parsed correctly + assertThat(appInfo.dependencies).hasSize(1); + assertThat(appInfo.dependencies).containsEntry("com.example:test-lib", "1.0.0"); + } finally { + System.setProperty("user.dir", originalDir); + } + } + + @Test + void testReadAppInfoWithoutActions() throws IOException { + // Create a test app.yml file without actions + Path appYmlPath = tempDir.resolve("app.yml"); + String yamlContent = "dependencies:\n" + " com.example:test-lib: \"1.0.0\"\n"; + Files.writeString(appYmlPath, yamlContent); + + String originalDir = System.getProperty("user.dir"); + System.setProperty("user.dir", tempDir.toString()); + + try { + AppInfo appInfo = AppInfo.read(); + + // Test no actions + assertThat(appInfo.getActionNames()).isEmpty(); + assertThat(appInfo.getAction("build")).isNull(); + + // Test dependencies are still parsed correctly + assertThat(appInfo.dependencies).hasSize(1); + } finally { + System.setProperty("user.dir", originalDir); + } + } + + @Test + void testReadEmptyAppInfo() throws IOException { + String originalDir = System.getProperty("user.dir"); + System.setProperty("user.dir", tempDir.toString()); + + try { + // No app.yml file exists + AppInfo appInfo = AppInfo.read(); + + // Test no actions and no dependencies + assertThat(appInfo.getActionNames()).isEmpty(); + assertThat(appInfo.dependencies).isEmpty(); + assertThat(appInfo.getAction("build")).isNull(); + } finally { + System.setProperty("user.dir", originalDir); + } + } + + @Test + void testWriteAppInfoWithActions() throws IOException { + AppInfo appInfo = new AppInfo(); + appInfo.dependencies.put("com.example:test-lib", "1.0.0"); + appInfo.actions.put("build", "javac -cp {{deps}} *.java"); + appInfo.actions.put("test", "java -cp {{deps}} TestRunner"); + + String originalDir = System.getProperty("user.dir"); + System.setProperty("user.dir", tempDir.toString()); + + try { + AppInfo.write(appInfo); + + // Verify the file was written + Path appYmlPath = tempDir.resolve("app.yml"); + assertThat(appYmlPath).exists(); + + // Read it back and verify + AppInfo readBack = AppInfo.read(); + assertThat(readBack.getAction("build")).isEqualTo("javac -cp {{deps}} *.java"); + assertThat(readBack.getAction("test")).isEqualTo("java -cp {{deps}} TestRunner"); + assertThat(readBack.getActionNames()).hasSize(2); + assertThat(readBack.dependencies).hasSize(1); + } finally { + System.setProperty("user.dir", originalDir); + } + } + + @Test + void testAppInfoWithComplexActions() throws IOException { + Path appYmlPath = tempDir.resolve("app.yml"); + String yamlContent = + "dependencies:\n" + + " com.example:test-lib: \"1.0.0\"\n" + + "\n" + + "actions:\n" + + " complex: \"java -cp {{deps}} -Dprop=value MainClass arg1 arg2\"\n" + + " quoted: 'echo \"Hello with spaces\"'\n" + + " multiline: >\n" + + " java -cp {{deps}}\n" + + " -Xmx1g\n" + + " MainClass\n"; + Files.writeString(appYmlPath, yamlContent); + + String originalDir = System.getProperty("user.dir"); + System.setProperty("user.dir", tempDir.toString()); + + try { + AppInfo appInfo = AppInfo.read(); + + assertThat(appInfo.getAction("complex")) + .isEqualTo("java -cp {{deps}} -Dprop=value MainClass arg1 arg2"); + assertThat(appInfo.getAction("quoted")).isEqualTo("echo \"Hello with spaces\""); + assertThat(appInfo.getAction("multiline")).contains("java -cp {{deps}}"); + assertThat(appInfo.getActionNames()).hasSize(3); + } finally { + System.setProperty("user.dir", originalDir); + } + } +} diff --git a/src/test/java/org/codejive/jpm/util/ScriptUtilsTest.java b/src/test/java/org/codejive/jpm/util/ScriptUtilsTest.java new file mode 100644 index 0000000..45aa14b --- /dev/null +++ b/src/test/java/org/codejive/jpm/util/ScriptUtilsTest.java @@ -0,0 +1,159 @@ +package org.codejive.jpm.util; + +import static org.assertj.core.api.Assertions.*; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Tests for ScriptUtils class, focusing on command processing and variable substitution. */ +class ScriptUtilsTest { + + @Test + void testProcessCommandWithDepsSubstitution() throws Exception { + List classpath = + Arrays.asList( + Paths.get("deps/lib1.jar"), + Paths.get("deps/lib2.jar"), + Paths.get("deps/lib3.jar")); + + String command = "java -cp {{deps}} MainClass"; + String result = ScriptUtils.processCommand(command, classpath); + + // Use the actual paths from the classpath as they would be processed + String expectedClasspath = + String.join( + File.pathSeparator, + classpath.get(0).toString(), + classpath.get(1).toString(), + classpath.get(2).toString()); + assertThat(result).isEqualTo("java -cp " + expectedClasspath + " MainClass"); + } + + @Test + void testProcessCommandWithoutDepsSubstitution() throws Exception { + List classpath = Arrays.asList(Paths.get("deps/lib1.jar")); + String command = "echo Hello World"; + String result = ScriptUtils.processCommand(command, classpath); + // Command should remain unchanged since no {{deps}} variable + assertThat(result).isEqualTo("echo Hello World"); + } + + @Test + void testProcessCommandWithEmptyClasspath() throws Exception { + List classpath = Collections.emptyList(); + String command = "java -cp \"{{deps}}\" MainClass"; + String result = ScriptUtils.processCommand(command, classpath); + // {{deps}} should be replaced with empty string + assertThat(result).isEqualTo("java -cp \"\" MainClass"); + } + + @Test + void testProcessCommandWithNullClasspath() throws Exception { + String command = "java -cp \"{{deps}}\" MainClass"; + String result = ScriptUtils.processCommand(command, null); + // {{deps}} should be replaced with empty string + assertThat(result).isEqualTo("java -cp \"\" MainClass"); + } + + @Test + void testProcessCommandWithPathTokens() throws Exception { + String command = "java -cp .{/}libs{:}.{/}ext{:}{~}{/}usrlibs MainClass"; + String result = ScriptUtils.processCommand(command, null); + String expectedPath = + ScriptUtils.isWindows() + ? ".\\libs;.\\ext;" + System.getProperty("user.home") + "\\usrlibs" + : "./libs:./ext:~/usrlibs"; + assertThat(result).isEqualTo("java -cp " + expectedPath + " MainClass"); + } + + @Test + void testProcessCommandWithPathReplacement() throws Exception { + String command = "java -cp {./libs:./ext:~/usrlibs} MainClass"; + String result = ScriptUtils.processCommand(command, null); + String expectedPath = + ScriptUtils.isWindows() + ? ".\\libs;.\\ext;" + System.getProperty("user.home") + "\\usrlibs" + : "./libs:./ext:~/usrlibs"; + assertThat(result).isEqualTo("java -cp " + expectedPath + " MainClass"); + } + + @Test + void testProcessCommandWithPathReplacement2() throws Exception { + String command = "java -cp {~/usrlibs:./libs:./ext} MainClass"; + String result = ScriptUtils.processCommand(command, null); + String expectedPath = + ScriptUtils.isWindows() + ? System.getProperty("user.home") + "\\usrlibs;.\\libs;.\\ext" + : "~/usrlibs:./libs:./ext"; + assertThat(result).isEqualTo("java -cp " + expectedPath + " MainClass"); + } + + @Test + void testProcessCommandWithMultipleDepsReferences() throws Exception { + List classpath = Arrays.asList(Paths.get("deps/lib1.jar")); + String command = "java -cp {{deps}} MainClass && java -cp {{deps}} TestClass"; + String result = ScriptUtils.processCommand(command, classpath); + + // Use the actual path as it would be processed + String expectedPath = classpath.get(0).toString(); + assertThat(result) + .isEqualTo( + "java -cp " + + expectedPath + + " MainClass && java -cp " + + expectedPath + + " TestClass"); + } + + @Test + void testIsWindows() { + boolean result = ScriptUtils.isWindows(); + // The result should match the current OS + String os = System.getProperty("os.name").toLowerCase(); + assertThat(result).isEqualTo(os.contains("win")); + } + + @Test + void testExecuteScriptSimpleCommand() { + // Test that executeScript can be called without throwing exceptions + // We can't easily test the actual execution without mocking ProcessBuilder + assertThatCode( + () -> { + // Use a simple command that should work on most systems + List classpath = Collections.emptyList(); + // Note: This test is limited because we can't easily mock + // ProcessBuilder + // In a real scenario, you might want to use a mocking framework + }) + .doesNotThrowAnyException(); + } + + @Test + void testProcessCommandIntegration() throws Exception { + // Integration test combining variable substitution and path handling + List classpath = + Arrays.asList(Paths.get("deps/lib1.jar"), Paths.get("deps/lib2.jar")); + + String command = "java -cp .{:}.{/}libs{/}*{:}{{deps}} -Dmyprop=value MainClass arg1"; + String result = ScriptUtils.processCommand(command, classpath); + + // Use the actual paths as they would be processed + String expectedClasspath = + String.join( + File.pathSeparator, + classpath.get(0).toString(), + classpath.get(1).toString()); + if (ScriptUtils.isWindows()) { + expectedClasspath = ".;.\\libs\\*;" + expectedClasspath; + } else { + expectedClasspath = ".:./libs/*:" + expectedClasspath; + } + assertThat(result) + .isEqualTo("java -cp " + expectedClasspath + " -Dmyprop=value MainClass arg1"); + } +}