diff --git a/src/main/java/rife/bld/BaseProject.java b/src/main/java/rife/bld/BaseProject.java
index 19c4839..042ae23 100644
--- a/src/main/java/rife/bld/BaseProject.java
+++ b/src/main/java/rife/bld/BaseProject.java
@@ -11,6 +11,8 @@ import rife.bld.operations.*;
import rife.tools.FileUtils;
import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.*;
import java.util.regex.Pattern;
@@ -925,14 +927,40 @@ public class BaseProject extends BuildExecutor {
*
* If the local dependency points to a directory, it will be scanned for jar files.
*
- * @param path the file system path of the local dependency
+ * @param path the file system path (absolute or relative to the {@link #workDirectory})
+ * of the local dependency
* @since 1.5.2
*/
-
public LocalDependency local(String path) {
return new LocalDependency(path);
}
+ /**
+ * Creates a local dependency instance.
+ *
+ * If the local dependency points to a directory, it will be scanned for jar files.
+ *
+ * @param path the file system path (absolute or relative to the {@link #workDirectory})
+ * of the local dependency
+ * @since 2.4.0
+ */
+ public LocalDependency local(Path path) {
+ return new LocalDependency(path.toString());
+ }
+
+ /**
+ * Creates a local dependency instance.
+ *
+ * If the local dependency points to a directory, it will be scanned for jar files.
+ *
+ * @param path the file system path (absolute or relative to the {@link #workDirectory})
+ * of the local dependency
+ * @since 2.4.0
+ */
+ public LocalDependency local(File path) {
+ return new LocalDependency(path.getPath());
+ }
+
/**
* Creates a new module instance.
*
@@ -1076,14 +1104,40 @@ public class BaseProject extends BuildExecutor {
*
* If the local module points to a directory, it will be scanned for jar files.
*
- * @param path the file system path of the local module
+ * @param path the file system path (absolute or relative to the {@link #workDirectory})
+ * of the local module
* @since 2.1
*/
-
public LocalModule localModule(String path) {
return new LocalModule(path);
}
+ /**
+ * Creates a local module instance.
+ *
+ * If the local module points to a directory, it will be scanned for jar files.
+ *
+ * @param path the file system path (absolute or relative to the {@link #workDirectory})
+ * of the local module
+ * @since 2.4.0
+ */
+ public LocalModule localModule(Path path) {
+ return new LocalModule(path.toString());
+ }
+
+ /**
+ * Creates a local module instance.
+ *
+ * If the local module points to a directory, it will be scanned for jar files.
+ *
+ * @param path the file system path (absolute or relative to the {@link #workDirectory})
+ * of the local module
+ * @since 2.4.0
+ */
+ public LocalModule localModule(File path) {
+ return new LocalModule(path.getPath());
+ }
+
/*
* Project directories
*/
@@ -1942,14 +1996,25 @@ public class BaseProject extends BuildExecutor {
}
private void addLocalJars(List jars, String path) {
- var local_file = new File(workDirectory(), path);
- if (local_file.exists()) {
- if (local_file.isDirectory()) {
- var local_jar_files = FileUtils.getFileList(local_file.getAbsoluteFile(), INCLUDED_JARS, EXCLUDED_JARS);
- jars.addAll(new ArrayList<>(local_jar_files.stream().map(file -> new File(local_file, file)).toList()));
- } else {
- jars.add(local_file);
+ var local_path = Path.of(path);
+ if (!local_path.isAbsolute()) {
+ local_path = workDirectory().toPath().resolve(path);
+ }
+
+ if (!Files.exists(local_path)) {
+ if (verbose()) {
+ System.err.println("Invalid local dependency or module path, skipped: " + path);
}
+ return;
+ }
+
+ if (Files.isDirectory(local_path)) {
+ var local_jar_files = FileUtils.getFileList(local_path.toFile(), INCLUDED_JARS, EXCLUDED_JARS);
+ for (var jar : local_jar_files) {
+ jars.add(local_path.resolve(jar).toFile());
+ }
+ } else {
+ jars.add(local_path.toFile());
}
}
diff --git a/src/test/java/rife/bld/TestBaseProject.java b/src/test/java/rife/bld/TestBaseProject.java
new file mode 100644
index 0000000..263861b
--- /dev/null
+++ b/src/test/java/rife/bld/TestBaseProject.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright 2026 Erik C. Thauvin (https://erik.thauvin.net/)
+ * Licensed under the Apache License, Version 2.0 (the "License")
+ */
+package rife.bld;
+
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+import rife.bld.dependencies.Scope;
+
+import java.io.File;
+import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TestBaseProject {
+ private static Method ADD_LOCAL_JARS;
+ @TempDir
+ Path tempDir;
+ private BaseProject project;
+
+ @BeforeAll
+ static void initReflection() throws Exception {
+ ADD_LOCAL_JARS = BaseProject.class.getDeclaredMethod("addLocalJars", List.class, String.class);
+ ADD_LOCAL_JARS.setAccessible(true);
+ }
+
+ @BeforeEach
+ void beforeEach() {
+ project = new BaseProject();
+ project.workDirectory = tempDir.toFile();
+ project.createProjectStructure();
+ }
+
+ private void invokeAddLocalJars(List jars, String path) throws Exception {
+ ADD_LOCAL_JARS.invoke(project, jars, path);
+ }
+
+ @Nested
+ @DisplayName("local(Path) and local(File)")
+ class LocalDependencyTest {
+
+ @Test
+ @DisplayName("local(Path) relative should resolve via workDirectory")
+ void localPathRelative() throws Exception {
+ var jar = Files.createFile(tempDir.resolve("a.jar"));
+ var dep = project.local(Path.of("a.jar"));
+
+ assertAll(
+ () -> assertNotNull(dep, "local(Path) should not return null"),
+ () -> assertEquals("a.jar", dep.path(), "relative Path should be kept as-is")
+ );
+
+ project.dependencies().scope(Scope.compile).include(dep);
+ assertTrue(project.compileClasspathJars().stream()
+ .anyMatch(f -> f.getAbsolutePath().equals(jar.toFile().getAbsolutePath())),
+ "compileClasspathJars should resolve relative path against workDirectory");
+ }
+
+ @Test
+ @DisplayName("local(Path) absolute should be kept absolute")
+ void localPathAbsolute() throws Exception {
+ var jar = Files.createFile(tempDir.resolve("abs.jar"));
+ var dep = project.local(jar);
+
+ assertAll(
+ () -> assertNotNull(dep, "local(Path) absolute should not return null"),
+ () -> assertEquals(jar.toString(), dep.path(), "absolute Path should be kept absolute")
+ );
+
+ project.dependencies().scope(Scope.compile).include(dep);
+ assertTrue(project.compileClasspathJars().stream()
+ .anyMatch(f -> f.getName().equals("abs.jar")),
+ "compileClasspathJars should include absolute Path dependency");
+ }
+
+ @Test
+ @DisplayName("local(File) relative file should be kept as-is")
+ void localFileRelative() {
+ var dep = project.local(new File("b.jar"));
+
+ assertAll(
+ () -> assertNotNull(dep, "local(File) should not return null"),
+ () -> assertEquals("b.jar", dep.path(), "relative File should be kept as-is")
+ );
+ }
+
+ @Test
+ @DisplayName("local(Path) absolute outside the project should resolve")
+ void localPathAbsoluteOutsideProject(@TempDir Path otherDir) throws Exception {
+ var jar = Files.createFile(otherDir.resolve("outside.jar"));
+ project.dependencies().scope(Scope.compile).include(project.local(jar));
+
+ assertTrue(project.compileClasspathJars().stream()
+ .anyMatch(f -> f.getAbsolutePath().equals(jar.toFile().getAbsolutePath())),
+ "compileClasspathJars should include an absolute path outside the project");
+ }
+
+ @Test
+ @DisplayName("local(File) absolute file")
+ void localFileAbsolute() throws Exception {
+ var jar = Files.createFile(tempDir.resolve("c.jar"));
+ var dep = project.local(jar.toFile());
+
+ assertAll(
+ () -> assertNotNull(dep, "local(File) absolute should not return null"),
+ () -> assertEquals(jar.toFile().getAbsolutePath(), dep.path(),
+ "absolute File should be kept absolute")
+ );
+ }
+
+ @Test
+ @DisplayName("local(File) directory should scan jars excluding sources/javadoc")
+ void localFileDirectory() throws Exception {
+ var dir = Files.createDirectory(tempDir.resolve("libs-dep-filter"));
+ Files.createFile(dir.resolve("one.jar"));
+ Files.createFile(dir.resolve("one-sources.jar"));
+ Files.createFile(dir.resolve("one-JAVADOC.jar"));
+
+ var dep = project.local(dir.toFile());
+ project.dependencies().scope(Scope.compile).include(dep);
+
+ var cp = project.compileClasspathJars();
+ assertAll(
+ () -> assertTrue(cp.stream().anyMatch(
+ f -> f.getName().equals("one.jar")), "regular jar should be included"),
+ () -> assertFalse(cp.stream().anyMatch(f -> f.getName().equals("one-sources.jar")),
+ "sources jar should be excluded (case-insensitive)"),
+ () -> assertFalse(cp.stream().anyMatch(
+ f -> f.getName().toLowerCase().contains("javadoc")),
+ "javadoc jar should be excluded")
+ );
+ }
+
+ @Test
+ @DisplayName("local(Path) and local(String) should behave same")
+ void parityWithStringOverload() throws Exception {
+ Files.createFile(tempDir.resolve("parity.jar"));
+
+ var fromString = project.local("parity.jar");
+ var fromPath = project.local(Path.of("parity.jar"));
+ var fromFile = project.local(new File("parity.jar"));
+
+ assertAll(
+ () -> assertEquals(fromString.path(), fromPath.path(),
+ "String and Path overloads should have same path()"),
+ () -> assertEquals(fromString.path(), fromFile.path(),
+ "String and File overloads should have same path()")
+ );
+ }
+ }
+
+ @Nested
+ @DisplayName("addLocalJars - private implementation")
+ class LocalJarsTest {
+ @Test
+ @DisplayName("should add relative file path")
+ void relativeFile() throws Exception {
+ Files.createFile(tempDir.resolve("my.jar"));
+ var jars = new ArrayList();
+ invokeAddLocalJars(jars, "my.jar");
+ assertEquals(1, jars.size(), "relative file that exists should be added");
+ }
+
+ @Test
+ @DisplayName("should add absolute file path")
+ void absoluteFile() throws Exception {
+ var jar = Files.createFile(tempDir.resolve("abs2.jar"));
+ var jars = new ArrayList();
+ invokeAddLocalJars(jars, jar.toAbsolutePath().toString());
+ assertEquals(1, jars.size(), "absolute file that exists should be added");
+ }
+
+ @Test
+ @DisplayName("should resolve relative to workDirectory")
+ void relativeToWorkDirectory() throws Exception {
+ var sub = Files.createDirectory(tempDir.resolve("sub"));
+ var jar = Files.createFile(sub.resolve("x.jar"));
+ var jars = new ArrayList();
+ invokeAddLocalJars(jars, "sub/x.jar");
+ assertEquals(jar.toFile().getAbsoluteFile(), jars.get(0).getAbsoluteFile(),
+ "should resolve against workDirectory");
+ }
+
+ @Test
+ @DisplayName("should scan directory for jars only")
+ void directoryScansJars() throws Exception {
+ var libDir = Files.createDirectory(tempDir.resolve("libs-jars-scan"));
+ Files.createFile(libDir.resolve("a.jar"));
+ Files.createFile(libDir.resolve("b.jar"));
+ Files.createFile(libDir.resolve("not-a-jar.txt"));
+ var jars = new ArrayList();
+ invokeAddLocalJars(jars, "libs-jars-scan");
+ assertEquals(2, jars.size(), "should only include .jar files");
+ }
+
+ @Test
+ @DisplayName("should exclude -sources and -javadoc jars in directory")
+ void directoryExcludesSourcesAndJavadoc() throws Exception {
+ var libDir = Files.createDirectory(tempDir.resolve("libs2-filter"));
+ Files.createFile(libDir.resolve("foo.jar"));
+ Files.createFile(libDir.resolve("foo-sources.jar"));
+ Files.createFile(libDir.resolve("foo-javadoc.jar"));
+ var jars = new ArrayList();
+ invokeAddLocalJars(jars, "libs2-filter");
+ assertAll(
+ () -> assertEquals(1, jars.size(), "should filter out -sources and -javadoc"),
+ () -> assertEquals("foo.jar", jars.get(0).getName(), "only foo.jar should remain")
+ );
+ }
+
+ @Nested
+ @DisplayName("integration via public classpath methods")
+ class Integration {
+ @Test
+ @DisplayName("compileClasspathJars should include local file dependency")
+ void viaCompileClasspath() throws Exception {
+ var myJar = Files.createFile(tempDir.resolve("custom.jar"));
+ project.dependencies().scope(Scope.compile).include(project.local("custom.jar"));
+ var classpath = project.compileClasspathJars();
+ assertTrue(classpath.stream().anyMatch(
+ f -> f.getAbsolutePath().equals(myJar.toFile().getAbsolutePath())),
+ "compileClasspathJars should include local file dependency");
+ }
+ }
+
+ @Nested
+ @DisplayName("verbose branch")
+ class VerboseBranch {
+ @BeforeEach
+ void enableVerbose() {
+ project = new BaseProject() {
+ @Override
+ public boolean verbose() {
+ return true;
+ }
+ };
+ project.workDirectory = tempDir.toFile();
+ project.createProjectStructure();
+ }
+
+ @Test
+ @DisplayName("should skip non-existent path without throwing, even verbose")
+ void nonExistentPathSkipped() {
+ var jars = new ArrayList();
+ assertDoesNotThrow(() -> invokeAddLocalJars(jars, "does-not-exist-verbose"),
+ "should not throw for missing path");
+ assertTrue(jars.isEmpty(), "non-existent path should be skipped");
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("localModule(Path) and localModule(File)")
+ class LocalModuleTest {
+ @Test
+ @DisplayName("localModule(Path) relative")
+ void localModulePathRelative() {
+ var mod = project.localModule(Path.of("mod.jar"));
+ assertAll(
+ () -> assertNotNull(mod, "should not return null"),
+ () -> assertEquals("mod.jar", mod.path(), "relative path kept as-is")
+ );
+ }
+
+ @Test
+ @DisplayName("localModule(Path) absolute")
+ void localModulePathAbsolute() throws Exception {
+ var jar = Files.createFile(tempDir.resolve("modAbs.jar"));
+ var mod = project.localModule(jar);
+ assertAll(
+ () -> assertNotNull(mod, "should not return null"),
+ () -> assertEquals(jar.toString(), mod.path(), "absolute path kept absolute")
+ );
+ }
+
+ @Test
+ @DisplayName("localModule(File) absolute")
+ void localModuleFileAbsolute() throws Exception {
+ var jar = Files.createFile(tempDir.resolve("mod2.jar"));
+ var mod = project.localModule(jar.toFile());
+ assertAll(
+ () -> assertNotNull(mod, "should not return null"),
+ () -> assertEquals(jar.toFile().getAbsolutePath(), mod.path(),
+ "absolute File should be kept absolute")
+ );
+ }
+
+ @Test
+ @DisplayName("localModule(File) directory scan")
+ void localModuleFileDirectory() throws Exception {
+ var dir = Files.createDirectory(tempDir.resolve("mods-filter"));
+ Files.createFile(dir.resolve("m1.jar"));
+ Files.createFile(dir.resolve("m1-javadoc.jar"));
+
+ var mod = project.localModule(dir.toFile());
+ project.dependencies().scope(Scope.compile).include(mod);
+
+ var mp = project.compileModulePathJars();
+ assertAll(
+ () -> assertTrue(mp.stream().anyMatch(f -> f.getName().equals("m1.jar")),
+ "regular jar should be in module path"),
+ () -> assertFalse(mp.stream().anyMatch(f -> f.getName().contains("javadoc")),
+ "javadoc jar should be excluded")
+ );
+ }
+
+ @Test
+ @DisplayName("all three overloads are consistent")
+ void overloadsConsistent() throws Exception {
+ Files.createFile(tempDir.resolve("cons.jar"));
+
+ var s = project.localModule("cons.jar");
+ var p = project.localModule(Path.of("cons.jar"));
+ var f = project.localModule(new File("cons.jar"));
+
+ assertAll(
+ () -> assertEquals(s.path(), p.path(), "String and Path overloads equal"),
+ () -> assertEquals(s.path(), f.path(), "String and File overloads equal")
+ );
+ }
+ }
+}