diff --git a/build.gradle b/build.gradle
index 85220bc..3423174 100644
--- a/build.gradle
+++ b/build.gradle
@@ -30,6 +30,10 @@ subprojects {
dependencies {
testCompile "org.testng:testng:6.14.3"
+ testCompile "com.google.jimfs:jimfs:1.1"
+ testCompile "org.apache.commons:commons-lang3:3.7"
+
+ compile 'commons-io:commons-io:2.5'
}
test {
diff --git a/core/- b/core/-
new file mode 100644
index 0000000..9dfbf8b
--- /dev/null
+++ b/core/-
@@ -0,0 +1 @@
+some stuff
\ No newline at end of file
diff --git a/core/src/main/java/org/htsjdk/core/api/io/IOResource.java b/core/src/main/java/org/htsjdk/core/api/io/IOResource.java
new file mode 100644
index 0000000..02f8fdc
--- /dev/null
+++ b/core/src/main/java/org/htsjdk/core/api/io/IOResource.java
@@ -0,0 +1,90 @@
+package org.htsjdk.core.api.io;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.Optional;
+
+/**
+ * Interface representing htsjdk-next input/output resources.
+ */
+public interface IOResource {
+
+ /**
+ * Determine if this resource has a scheme that has an installed NIO file system provider. This does not
+ * guarantee the resource can be converted into a {@code java.nio.file.Path}, since the resource can be
+ * syntactically valid, and specify a valid file system provider, but still fail to be semantically meaningful.
+ * @return true if this URI has a scheme that has an installed NIO file system provider.
+ */
+ boolean isNIO();
+
+ /**
+ * Return true if this {code IOResource} can be resolved to an NIO Path. If true, {@code #toPath()} can be
+ * safely called.
+ *
+ * There are cases where a valid URI with a valid scheme backed by an installed NIO File System
+ * still can't be turned into a {@code java.nio.file.Path}, i.e., the following specifies an invalid
+ * authority "namenode":
+ *
+ * file://namenode/to/file
+ *
+ * @return {@code true} if this {@code IOResource} can be resolved to an NIO Path.
+ */
+ boolean isPath();
+
+ /**
+ * Get a {@code java.net.URI} object for this {@code IOResource}. Will not be null.
+ * @return The {@code URI} object for this IOResource.
+ */
+ URI getURI();
+
+ /**
+ * Returns the String representation of the {{@code UR} backing this {@code IOResource} URI. This string
+ * may differ from the normalized string returned from a Path that has been object resolved from this
+ * IOResource.
+ *
+ * @return String from which this URI as originally created. Will not be null, and will always
+ * include a URI scheme.
+ */
+ default String getURIString() { return getURI().toString(); }
+
+ /**
+ * Return the raw (source) input used to create this {@code IOResource} as a String.
+ */
+ String getRawInputString();
+
+ /**
+ * Resolve this {@code IOResource} to an NIO Path. Can be safely called only if {@link #isPath()} returns true.
+ */
+ Path toPath();
+
+ /**
+ * Return a string message describing why this IOResource cannot be converted to a {@code java.nio.file.Path}
+ * ({@code #isPath()} returns false).
+ *
+ * @return Optional message explaining toPath failure reason, since it can fail for various reasons.
+ */
+ Optional getToPathFailureReason();
+
+ /**
+ * Return the scheme for this IOResource. For file resources (URIs that have no explicit scheme), this
+ * will return the scheme "file".
+ * @return the scheme String for the URI backing this {@code IOResource}, if any. Will not be null.
+ */
+ default String getScheme() {
+ return getURI().getScheme();
+ }
+
+ /**
+ * Get a {@code InputStream} for this resource.
+ * @return {@code InputStream} for this resource.
+ */
+ InputStream getInputStream();
+
+ /**
+ * Get an {@code OutputStream} for this resource.
+ * @return {@code OutputStream} for this URI.
+ */
+ OutputStream getOutputStream();
+}
diff --git a/core/src/main/java/org/htsjdk/core/exception/HtsjdkIOException.java b/core/src/main/java/org/htsjdk/core/exception/HtsjdkIOException.java
new file mode 100644
index 0000000..0ef0d19
--- /dev/null
+++ b/core/src/main/java/org/htsjdk/core/exception/HtsjdkIOException.java
@@ -0,0 +1,32 @@
+package org.htsjdk.core.exception;
+
+public class HtsjdkIOException extends HtsjdkException {
+
+ /**
+ * Constructs an HtsjdkIOException exception.
+ *
+ * @param message detailed message.
+ */
+ public HtsjdkIOException(String message) {
+ super(message);
+ }
+
+ /**
+ * Constructs an HtsjdkIOException exception with a specified cause.
+ *
+ * @param message detailed message.
+ * @param cause cause of the exception.
+ */
+ public HtsjdkIOException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ /**
+ * Constructs an HtsjdkIOException exception with a message constructed from the cause.
+ *
+ * @param cause cause of the exception.
+ */
+ public HtsjdkIOException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/core/src/main/java/org/htsjdk/core/utils/IOUtils.java b/core/src/main/java/org/htsjdk/core/utils/IOUtils.java
new file mode 100644
index 0000000..5055830
--- /dev/null
+++ b/core/src/main/java/org/htsjdk/core/utils/IOUtils.java
@@ -0,0 +1,22 @@
+package org.htsjdk.core.utils;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+
+public class IOUtils {
+
+ /**
+ * Create a temporary file using a given name prefix and name suffix and return a {@link java.nio.file.Path}.
+ * @param prefix
+ * @param suffix
+ * @return temp File that will be deleted on exit
+ * @throws IOException
+ */
+ public static Path createTempPath(final String prefix, final String suffix) throws IOException {
+ final File tempFile = File.createTempFile(prefix, suffix);
+ tempFile.deleteOnExit();
+ return tempFile.toPath();
+ }
+
+}
diff --git a/core/src/main/java/org/htsjdk/core/utils/PathSpecifier.java b/core/src/main/java/org/htsjdk/core/utils/PathSpecifier.java
new file mode 100644
index 0000000..070e63f
--- /dev/null
+++ b/core/src/main/java/org/htsjdk/core/utils/PathSpecifier.java
@@ -0,0 +1,258 @@
+package org.htsjdk.core.utils;
+
+import org.htsjdk.core.api.io.IOResource;
+import org.htsjdk.core.exception.HtsjdkException;
+import org.htsjdk.core.exception.HtsjdkIOException;
+
+import java.io.*;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.*;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.Optional;
+
+/**
+ * Default implementation for IOResource.
+ *
+ * This class takes a raw string that is to be interpreted as a path specifier, and converts it internally to a
+ * URI and/or Path object from which a stream can be obtained. If no scheme is provided as part of the raw string
+ * used in the constructor(s), the input is assumed to represent a file on the local file system, and will be
+ * backed by a URI with a "file:/" scheme and a path part that is automatically encoded/escaped to ensure it is
+ * a valid URI. If the raw string contains a scheme, it will be backed by a URI formed from the raw string as
+ * presented, with no further encoding/escaping.
+ *
+ * For example, a URI that contains a scheme and has an embedded "#" in the path will be treated as a URI
+ * having a fragment delimiter. If the URI contains an scheme, the "#" will be escaped and the encoded "#"
+ * will be interpreted as part of the URI path.
+ *
+ * There are 3 succeeding levels of input validation/conversion:
+ *
+ * 1) PathSpecifier constructor: requires a syntactically valid URI, possibly containing a scheme (if no scheme
+ * is present the path part will be escaped/encoded), or a valid local file reference
+ * 2) isNio: true if the input string is an identifier that is syntactically valid, and is backed by
+ * an installed NIO provider that matches the URI scheme
+ * 3) isPath: syntactically valid URI that can be resolved to a java.io.Path by the associated provider
+ *
+ * Definitions:
+ *
+ * "absolute" URI - specifies a scheme
+ * "relative" URI - does not specify a scheme
+ * "opaque" URI - an "absolute" URI whose scheme-specific part does not begin with a slash character
+ * "hierarchical" URI - either an "absolute" URI whose scheme-specific part begins with a slash character,
+ * or a "relative" URI (no scheme)
+ *
+ * URIs that do not make use of the slash "/" character for separating hierarchical components are
+ * considered "opaque" by the generic URI parser.
+ *
+ * General syntax for an "absolute" URI:
+ *
+ * :
+ *
+ * Many "hierarchical" URI schemes use this syntax:
+ *
+ * ://?
+ *
+ * More specifically:
+ *
+ * absoluteURI = scheme ":" ( hier_part | opaque_part )
+ * hier_part = ( net_path | abs_path ) [ "?" query ]
+ * net_path = "//" authority [ abs_path ]
+ * abs_path = "/" path_segments
+ * opaque_part = uric_no_slash *uric
+ * uric_no_slash = unreserved | escaped | ";" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","
+ */
+public class PathSpecifier implements IOResource, Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private final String rawInputString; // raw input string provided by th user; may or may not have a scheme
+ private final URI uri; // working URI; always has a scheme (assume "file" if not provided)
+ private transient Path cachedPath; // cache the Path associated with this URI if its "Path-able"
+ private Optional pathFailureReason = Optional.empty(); // cache the reason for "toPath" conversion failure
+
+ /**
+ * If the raw input string already contains a scheme (including a "file" scheme), assume its already
+ * properly escape/encoded. If no scheme component is present, assume it referencess a raw path on the
+ * local file system, so try to get a Path first, and then retrieve the URI from the resulting Path.
+ * This ensures that input strings that are local file references without a scheme component and contain
+ * embedded characters are valid in file names, but which would otherwise be interpreted as excluded
+ * URI characters (such as the URI fragment delimiter "#") are properly escape/encoded.
+ * @param rawInputString
+ */
+ public PathSpecifier(final String rawInputString) {
+ ParamUtils.nonNull(rawInputString);
+ this.rawInputString = rawInputString;
+
+ URI tempURI;
+ try {
+ tempURI = new URI(rawInputString);
+ if (!tempURI.isAbsolute()) {
+ // if the URI has no scheme, assume its a local (non-URI) file reference, and resolve
+ // it to a Path and retrieve the URI from the Path to ensure proper escape/encoding
+ setCachedPath(Paths.get(rawInputString));
+ tempURI = getCachedPath().toUri();
+ }
+ } catch (URISyntaxException uriException) {
+ // the input string isn't a valid URI; assume its a local (non-URI) file reference, and
+ // use the URI resulting from the corresponding Path
+ try {
+ setCachedPath(Paths.get(rawInputString));
+ tempURI = getCachedPath().toUri();
+ } catch (InvalidPathException | UnsupportedOperationException | SecurityException pathException) {
+ // we have two exceptions, each of which might be relevant since we can't tell whether
+ // the user intended to provide a local file reference or a URI, so preserve both messages
+ final String errorMessage = String.format(
+ "%s can't be interpreted as a local file (%s) or as a URI (%s).",
+ rawInputString,
+ pathException.getMessage(),
+ uriException.getMessage());
+ throw new IllegalArgumentException(errorMessage, pathException);
+ }
+ }
+ if (!tempURI.isAbsolute()) {
+ // assert the invariant that every URI we create has a scheme, even if the raw input string does not
+ throw new HtsjdkIOException("URI has no scheme");
+ }
+
+ uri = tempURI;
+ }
+
+ @Override
+ public boolean isNIO() {
+ // try to find a provider; assume that our URI always has a scheme
+ for (FileSystemProvider provider: FileSystemProvider.installedProviders()) {
+ if (provider.getScheme().equalsIgnoreCase(uri.getScheme())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean isPath() {
+ try {
+ return toPath() != null;
+ } catch (ProviderNotFoundException |
+ FileSystemNotFoundException |
+ IllegalArgumentException |
+ HtsjdkException |
+ // thrown byjimfs
+ AssertionError e) {
+ // jimfs throws an AssertionError that wraps a URISyntaxException when trying to create path where
+ // the scheme-specific part is missing or incorrect
+ pathFailureReason = Optional.of(e.getMessage());
+ return false;
+ }
+ }
+
+ @Override
+ public URI getURI() {
+ return uri;
+ }
+
+ @Override
+ public String getURIString() {
+ return getURI().toString();
+ }
+
+ /**
+ * Return the raw input string as provided to the constructor.
+ */
+ @Override
+ public String getRawInputString() { return rawInputString; }
+
+ /**
+ * Resolve the URI to a {@link Path} object.
+ *
+ * @return the resulting {@code Path}
+ */
+ @Override
+ public Path toPath() {
+ if (getCachedPath() != null) {
+ return getCachedPath();
+ } else {
+ final Path tmpPath = Paths.get(getURI());
+ setCachedPath(tmpPath);
+ return tmpPath;
+ }
+ }
+
+ @Override
+ public Optional getToPathFailureReason() {
+ if (!pathFailureReason.isPresent()) {
+ try {
+ toPath();
+ return Optional.empty();
+ } catch (ProviderNotFoundException e) {
+ pathFailureReason = Optional.of(String.format("ProviderNotFoundException: %s", e.getMessage()));
+ } catch (FileSystemNotFoundException e) {
+ pathFailureReason = Optional.of(String.format("FileSystemNotFoundException: %s", e.getMessage()));
+ } catch (IllegalArgumentException e) {
+ pathFailureReason = Optional.of(String.format("IllegalArgumentException: %s", e.getMessage()));
+ } catch (HtsjdkException e) {
+ pathFailureReason = Optional.of(String.format("HtsjdkException: %s", e.getMessage()));
+ }
+ }
+ return pathFailureReason;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ if (!isPath()) {
+ throw new HtsjdkIOException(getToPathFailureReason().get());
+ }
+
+ final Path resourcePath = toPath();
+ try {
+ return Files.newInputStream(resourcePath);
+ } catch (IOException e) {
+ throw new HtsjdkIOException(
+ String.format("Could not create open input stream for %s (as URI %s)", getRawInputString(), getURIString()), e);
+ }
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ if (!isPath()) {
+ throw new HtsjdkIOException(getToPathFailureReason().get());
+ }
+
+ final Path resourcePath = toPath();
+ try {
+ return Files.newOutputStream(resourcePath);
+ } catch (IOException e) {
+ throw new HtsjdkIOException(String.format("Could not open output stream for %s (as URI %s)", getRawInputString(), getURIString()), e);
+ }
+ }
+
+ // get the cached path associated with this URI if its already been created
+ protected Path getCachedPath() { return cachedPath; }
+
+ protected void setCachedPath(Path path) {
+ this.cachedPath = path;
+ }
+
+ @Override
+ public String toString() {
+ return rawInputString;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof PathSpecifier)) return false;
+
+ PathSpecifier that = (PathSpecifier) o;
+
+ if (!getURIString().equals(that.getURIString())) return false;
+ if (!getURI().equals(that.getURI())) return false;
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = getURIString().hashCode();
+ result = 31 * result + getURI().hashCode();
+ return result;
+ }
+
+}
diff --git a/core/src/test/java/org/htsjdk/core/utils/PathSpecifierUnitTest.java b/core/src/test/java/org/htsjdk/core/utils/PathSpecifierUnitTest.java
new file mode 100644
index 0000000..4905d97
--- /dev/null
+++ b/core/src/test/java/org/htsjdk/core/utils/PathSpecifierUnitTest.java
@@ -0,0 +1,358 @@
+package org.htsjdk.core.utils;
+
+//TODO: NAMING: is there any useful distinction between Unit/Integration tests in this repo ?
+
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import org.apache.commons.lang3.SystemUtils;
+import org.htsjdk.core.api.io.IOResource;
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import java.io.*;
+import java.nio.file.*;
+
+public class PathSpecifierUnitTest {
+
+ final static String FS_SEPARATOR = FileSystems.getDefault().getSeparator();
+
+ @DataProvider
+ public Object[][] validPathSpecifiers() {
+ return new Object[][] {
+ // Paths specifiers that are syntactically valid as either a relative or absolute local file
+ // name, or as a URI, but which may fail isNIO or isPath
+
+ // input String, expected resulting URI String, expected isNIO, expected isPath
+
+ //********************************
+ // Local (non-URI) file references
+ //********************************
+
+ {"localFile.bam", "file://" + getCWDAsURIPathString() + "localFile.bam", true, true},
+ // absolute reference to a file in the root of the current file system (Windows accepts the "/" as root)
+ {"/localFile.bam", "file://" + getRootDirectoryAsURIPathString() + "localFile.bam", true, true},
+ // absolute reference to a file in the root of the current file system, where root is specified using the
+ // default FS separator
+ {FS_SEPARATOR + "localFile.bam", "file://" + getRootDirectoryAsURIPathString() + "localFile.bam", true, true},
+ // absolute reference to a file
+ {FS_SEPARATOR + joinWithFSSeparator("path", "to", "localFile.bam"),
+ "file://" + getRootDirectoryAsURIPathString() + "path/to/localFile.bam", true, true},
+ // absolute reference to a file that contains a URI "excluded" character in the path ("#"), which without
+ // encoding will be treated as a fragment delimiter
+ {FS_SEPARATOR + joinWithFSSeparator("project", "gvcf-pcr", "23232_1#1", "1.g.vcf.gz"),
+ "file://" + getRootDirectoryAsURIPathString() + "project/gvcf-pcr/23232_1%231/1.g.vcf.gz", true, true},
+ // relative reference to a file on the local file system
+ {joinWithFSSeparator("path", "to", "localFile.bam"),
+ "file://" + getCWDAsURIPathString() + "path/to/localFile.bam", true, true},
+ // Windows also accepts "/" as a valid root specifier
+ {"/", "file://" + getRootDirectoryAsURIPathString(), true, true},
+ {".", "file://" + getCWDAsURIPathString() + "./", true, true},
+ {"../.", "file://" + getCWDAsURIPathString() + ".././", true, true},
+ // an empty path is equivalent to accessing the current directory of the default file system
+ {"", "file://" + getCWDAsURIPathString(), true, true},
+
+ //***********************************************************
+ // Local file references using a URI with a "file://" scheme.
+ //***********************************************************
+
+ {"file:localFile.bam", "file:localFile.bam", true, false}, // absolute, opaque (not hierarchical)
+ {"file:/localFile.bam", "file:/localFile.bam", true, true}, // absolute, hierarchical
+ {"file://localFile.bam", "file://localFile.bam", true, false}, // file URLs can't have an authority ("localFile.bam")
+ {"file:///localFile.bam", "file:///localFile.bam", true, true}, // empty authority
+ {"file:path/to/localFile.bam", "file:path/to/localFile.bam", true, false},
+ {"file:/path/to/localFile.bam", "file:/path/to/localFile.bam", true, true},
+ // "path" appears to be an authority, and will be accepted on Windows since this URI will be
+ // interpreted as a UNC path containing an authority
+ {"file://path/to/localFile.bam", "file://path/to/localFile.bam", true, SystemUtils.IS_OS_WINDOWS},
+ // "localhost" is accepted as a special case authority for "file://" Paths on Windows; but not Linux/Mac
+ {"file://localhost/to/localFile.bam","file://localhost/to/localFile.bam", true, SystemUtils.IS_OS_WINDOWS},
+ {"file:///path/to/localFile.bam", "file:///path/to/localFile.bam", true, true}, // empty authority
+
+ //*****************************************************************************
+ // Valid URIs which are NOT valid NIO paths (no installed file system provider)
+ //*****************************************************************************
+
+ {"gs://file.bam", "gs://file.bam", false, false},
+ {"gs://bucket/file.bam", "gs://bucket/file.bam", false, false},
+ {"gs:///bucket/file.bam", "gs:///bucket/file.bam", false, false},
+ {"gs://auth/bucket/file.bam", "gs://auth/bucket/file.bam", false, false},
+ {"gs://hellbender/test/resources/", "gs://hellbender/test/resources/", false, false},
+ {"gcs://abucket/bucket", "gcs://abucket/bucket", false, false},
+ {"gendb://somegdb", "gendb://somegdb", false, false},
+ {"chr1:1-100", "chr1:1-100", false, false},
+
+ //*****************************************************************************************
+ // Valid URIs which are backed by an installed NIO file system provider), but are which not
+ // actually resolvable as paths because the scheme-specific part is not valid for one reason
+ // or another.
+ //**********************************************************************************************
+
+ // uri must have a path: jimfs:file.bam
+ {"jimfs:file.bam", "jimfs:file.bam", true, false},
+ // java.lang.AssertionError: java.net.URISyntaxException: Expected scheme-specific part at index 6: jimfs:
+ {"jimfs:/file.bam", "jimfs:/file.bam", true, false},
+ // java.lang.AssertionError: uri must have a path: jimfs://file.bam
+ {"jimfs://file.bam", "jimfs://file.bam", true, false},
+ // java.lang.AssertionError: java.net.URISyntaxException: Expected scheme-specific part at index 6: jimfs:
+ {"jimfs:///file.bam", "jimfs:///file.bam", true, false},
+ // java.nio.file.FileSystemNotFoundException: jimfs://root
+ {"jimfs://root/file.bam","jimfs://root/file.bam", true, false},
+
+ //***********************************************************************************************
+ // References that contain characters that require URI-encoding. If the input string is presented
+ // without no scheme, it will be be automatically encoded by PathSpecifier, otherwise it
+ // must already be URI-encoded.
+ //***********************************************************************************************
+
+ // relative (non-URI) reference to a file on the local file system that contains a URI fragment delimiter
+ // is automatically URI-encoded
+ {joinWithFSSeparator("project", "gvcf-pcr", "23232_1#1", "1.g.vcf.gz"),
+ "file://" + getCWDAsURIPathString() + "project/gvcf-pcr/23232_1%231/1.g.vcf.gz", true, true},
+ // URI reference with fragment delimiter is not automatically URI-encoded
+ {"file:project/gvcf-pcr/23232_1#1/1.g.vcf.gz", "file:project/gvcf-pcr/23232_1#1/1.g.vcf.gz", true, false},
+ {"file:/project/gvcf-pcr/23232_1#1/1.g.vcf.gz", "file:/project/gvcf-pcr/23232_1#1/1.g.vcf.gz", true, false},
+ {"file:///project/gvcf-pcr/23232_1%231/1.g.vcf.g", "file:///project/gvcf-pcr/23232_1%231/1.g.vcf.g", true, true},
+ };
+ }
+
+ @Test(dataProvider = "validPathSpecifiers")
+ public void testPathSpecifier(final String referenceString, final String expectedURIString, final boolean isNIO, final boolean isPath) {
+ final IOResource ioResource = new PathSpecifier(referenceString);
+ Assert.assertNotNull(ioResource);
+ Assert.assertEquals(ioResource.getURI().toString(), expectedURIString);
+ }
+
+ @Test(dataProvider = "validPathSpecifiers")
+ public void testIsNIO(final String referenceString, final String expectedURIString, final boolean isNIO, final boolean isPath) {
+ final IOResource pathURI = new PathSpecifier(referenceString);
+ Assert.assertEquals(pathURI.isNIO(), isNIO);
+ }
+
+ @Test(dataProvider = "validPathSpecifiers")
+ public void testIsPath(final String referenceString, final String expectedURIString, final boolean isNIO, final boolean isPath) {
+ final IOResource pathURI = new PathSpecifier(referenceString);
+ if (isPath) {
+ Assert.assertEquals(pathURI.isPath(), isPath, pathURI.getToPathFailureReason().orElse("no failure"));
+ } else {
+ Assert.assertEquals(pathURI.isPath(), isPath);
+ }
+ }
+
+ @Test(dataProvider = "validPathSpecifiers")
+ public void testToPath(final String referenceString, final String expectedURIString, final boolean isNIO, final boolean isPath) {
+ final IOResource pathURI = new PathSpecifier(referenceString);
+ if (isPath) {
+ final Path path = pathURI.toPath();
+ Assert.assertEquals(path != null, isPath, pathURI.getToPathFailureReason().orElse("no failure"));
+ } else {
+ Assert.assertEquals(pathURI.isPath(), isPath);
+ }
+ }
+
+ @DataProvider
+ public Object[][] invalidPathSpecifiers() {
+ return new Object[][] {
+ // the nul character is rejected on all of the supported platforms in both local
+ // filenames and URIs, so use it to test PathSpecifier constructor failure on all platforms
+ {"\0"},
+ };
+ }
+
+ @Test(dataProvider = "invalidPathSpecifiers", expectedExceptions = {IllegalArgumentException.class})
+ public void testPathSpecifierInvalid(final String referenceString) {
+ new PathSpecifier(referenceString);
+ }
+
+ @DataProvider
+ public Object[][] invalidPath() {
+ return new Object[][] {
+ // valid references that are not valid as a path
+
+ {"file:/project/gvcf-pcr/23232_1#1/1.g.vcf.gz"}, // not encoded
+ {"file:project/gvcf-pcr/23232_1#1/1.g.vcf.gz"}, // scheme-specific part is not hierarchical
+
+ // The hadoop file system provider explicitly throws an NPE if no host is specified and HDFS is not
+ // the default file system
+ //{"hdfs://nonexistent_authority/path/to/file.bam"}, // unknown authority "nonexistent_authority"
+ {"hdfs://userinfo@host:80/path/to/file.bam"}, // UnknownHostException "host"
+
+ {"unknownscheme://foobar"},
+ {"gendb://adb"},
+ {"gcs://abucket/bucket"},
+
+ // URIs with schemes that are backed by an valid NIO provider, but for which the
+ // scheme-specific part is not valid.
+ {"file://nonexistent_authority/path/to/file.bam"}, // unknown authority "nonexistent_authority"
+ };
+ }
+
+ @Test(dataProvider = "invalidPath")
+ public void testIsPathInvalid(final String invalidPathString) {
+ final IOResource htsURI = new PathSpecifier(invalidPathString);
+ Assert.assertFalse(htsURI.isPath());
+ }
+
+ @Test(dataProvider = "invalidPath", expectedExceptions = {IllegalArgumentException.class, FileSystemNotFoundException.class})
+ public void testToPathInvalid(final String invalidPathString) {
+ final IOResource htsURI = new PathSpecifier(invalidPathString);
+ htsURI.toPath();
+ }
+
+ @Test
+ public void testInstalledNonDefaultFileSystem() throws IOException {
+ // create a jimfs file system and round trip through PathSpecifier/stream
+ try (FileSystem jimfs = Jimfs.newFileSystem(Configuration.unix())) {
+ final Path outputPath = jimfs.getPath("alternateFileSystemTest.txt");
+ doStreamRoundTrip(outputPath.toUri().toString());
+ }
+ }
+
+ @DataProvider
+ public Object[][] inputStreamSpecifiers() throws IOException {
+ return new Object[][]{
+ // references that can be resolved to an actual test file that can be read
+
+ // relative (file) reference to a local file
+ {joinWithFSSeparator("..", "data", "utils", "testTextFile.txt"), "Test file."},
+
+ // absolute reference to a local file
+ {getCWDAsFileReference() + FS_SEPARATOR + joinWithFSSeparator("..", "data", "utils", "testTextFile.txt"), "Test file."},
+
+ // URI reference to a local file, where the path is absolute
+ {"file://" + getCWDAsURIPathString() + "../data/utils/testTextFile.txt", "Test file."},
+
+ // reference to a local file with an embedded fragment delimiter ("#") in the name; if the file
+ // scheme is included, the rest of the path must already be encoded; if no file scheme is
+ // included, the path is encoded by the PathSpecifier class
+ {joinWithFSSeparator("..", "data", "utils", "testDirWith#InName", "testTextFile.txt"), "Test file."},
+ {"file://" + getCWDAsURIPathString() + "../data/utils/testDirWith%23InName/testTextFile.txt", "Test file."},
+ };
+ }
+
+ @Test(dataProvider = "inputStreamSpecifiers")
+ public void testGetInputStream(final String referenceString, final String expectedFileContents) throws IOException {
+ final IOResource htsURI = new PathSpecifier(referenceString);
+
+ try (final InputStream is = htsURI.getInputStream();
+ final DataInputStream dis = new DataInputStream(is)) {
+ final byte[] actualFileContents = new byte[expectedFileContents.length()];
+ dis.readFully(actualFileContents);
+
+ Assert.assertEquals(new String(actualFileContents), expectedFileContents);
+ }
+ }
+
+ @DataProvider
+ public Object[][] outputStreamSpecifiers() throws IOException {
+ return new Object[][]{
+ // output URIs that can be resolved to an actual test file
+ {IOUtils.createTempPath("testOutputStream", ".txt").toString()},
+ {"file://" + getLocalFileAsURIPathString(IOUtils.createTempPath("testOutputStream", ".txt"))},
+ };
+ }
+
+ @Test(dataProvider = "outputStreamSpecifiers")
+ public void testGetOutputStream(final String referenceString) throws IOException {
+ doStreamRoundTrip(referenceString);
+ }
+
+ @Test
+ public void testStdIn() throws IOException {
+ final IOResource htsURI = new PathSpecifier(
+ SystemUtils.IS_OS_WINDOWS ?
+ "-" :
+ "/dev/stdin");
+ try (final InputStream is = htsURI.getInputStream();
+ final DataInputStream dis = new DataInputStream(is)) {
+ final byte[] actualFileContents = new byte[0];
+ dis.readFully(actualFileContents);
+
+ Assert.assertEquals(new String(actualFileContents), "");
+ }
+ }
+
+ @Test
+ public void testStdOut() throws IOException {
+ final IOResource pathURI = new PathSpecifier(
+ SystemUtils.IS_OS_WINDOWS ?
+ "-" :
+ "/dev/stdout");
+ try (final OutputStream os = pathURI.getOutputStream();
+ final DataOutputStream dos = new DataOutputStream(os)) {
+ dos.write("some stuff".getBytes());
+ }
+ }
+
+ /**
+ * Return the string resulting from joining the individual components using the local default
+ * file system separator.
+ *
+ * This is used to create test inputs that are local file references, as would be presented by a
+ * user on the platform on which these tests are running.
+ */
+ private String joinWithFSSeparator(String... parts) {
+ return String.join(FileSystems.getDefault().getSeparator(), parts);
+ }
+
+ private void doStreamRoundTrip(final String referenceString) throws IOException {
+ final String expectedFileContents = "Test contents";
+
+ final IOResource pathURI = new PathSpecifier(referenceString);
+ try (final OutputStream os = pathURI.getOutputStream();
+ final DataOutputStream dos = new DataOutputStream(os)) {
+ dos.write(expectedFileContents.getBytes());
+ }
+
+ // read it back in and make sure it matches expected contents
+ try (final InputStream is = pathURI.getInputStream();
+ final DataInputStream dis = new DataInputStream(is)) {
+ final byte[] actualFileContents = new byte[expectedFileContents.length()];
+ dis.readFully(actualFileContents);
+
+ Assert.assertEquals(new String(actualFileContents), expectedFileContents);
+ }
+ }
+
+ /**
+ * Get an absolute reference to the current working directory using local file system syntax and
+ * the local file system separator. Used to construct valid local, absolute file references as test inputs.
+ *
+ * Returns a string of the form '/some/path/.` or `d:\some\path\.` on Windows
+ */
+ private static String getCWDAsFileReference() {
+ return new File(".").getAbsolutePath();
+ }
+
+ /**
+ * Get the current working directory as a locally valid, hierarchical URI string. Used to
+ * construct expected URI string values for test inputs that are local file references.
+ *
+ * Returns '/some/path/` or `/d:/some/path/` on Windows
+ */
+ private String getCWDAsURIPathString() {
+ return getLocalFileAsURIPathString(Paths.get("."));
+ }
+
+ /**
+ * Get just the path part of the URI representing the current working directory. Used
+ * to construct expected URI string values for test inputs that specify a file in the
+ * root of the local file system.
+ *
+ * Returns a string of the form "/" or "/d:/" on Windows.
+ */
+ private String getRootDirectoryAsURIPathString() {
+ return getLocalFileAsURIPathString(Paths.get(FS_SEPARATOR));
+ }
+
+ /**
+ * Get just the path part of the URI representing a file on the local file system as a normalized
+ * String.
+ *
+ * Returns a string of the form `/some/path' or '/d:/some/path/` on Windows.
+ */
+ private String getLocalFileAsURIPathString(final Path localPath) {
+ return localPath.toUri().normalize().getPath();
+ }
+
+}
diff --git a/data/utils/testDirWith#InName/testTextFile.txt b/data/utils/testDirWith#InName/testTextFile.txt
new file mode 100644
index 0000000..f5d299f
--- /dev/null
+++ b/data/utils/testDirWith#InName/testTextFile.txt
@@ -0,0 +1,2 @@
+Test file.
+
diff --git a/data/utils/testTextFile.txt b/data/utils/testTextFile.txt
new file mode 100644
index 0000000..f5d299f
--- /dev/null
+++ b/data/utils/testTextFile.txt
@@ -0,0 +1,2 @@
+Test file.
+