Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
kapacity = "0.9.9-beta08"
kapacity = "0.9.9-beta09"
agp = "8.11.2"
android-compileSdk = "36"
android-minSdk = "24"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package io.github.developrofthings.kapacity.io

import io.github.developrofthings.kapacity.Kapacity
import io.github.developrofthings.kapacity.byte
import java.io.ByteArrayInputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith


class InputStreamKapacityTest {

@Test
fun testReadFillsDestinationCorrectlyWithinKapacity() {
val data = byteArrayOf(1, 2, 3, 4, 5)
val stream = ByteArrayInputStream(data)
val destination = ByteArray(5)

val bytesRead = stream.read(destination, kapacity = 3.byte)

assertEquals(3, bytesRead)
assertEquals(1, destination[0])
assertEquals(3, destination[2])
assertEquals(0, destination[3]) // Remaining bytes should be untouched
}

@Test
fun testReadReturnsMinusOneAtEndOfStream() {
val stream = ByteArrayInputStream(byteArrayOf())
val destination = ByteArray(10)

val bytesRead = stream.read(destination, kapacity = 5.byte)

assertEquals(-1, bytesRead)
}

@Test
fun testReadCoercesLengthToAvailableSpaceInDestination() {
val data = byteArrayOf(10, 20, 30, 40, 50)
val stream = ByteArrayInputStream(data)
val destination = ByteArray(3) // Smaller than the data and the requested kapacity

// We request 5 bytes, but the buffer only has 3 slots.
val bytesRead = stream.read(destination, kapacity = 5.byte)

assertEquals(3, bytesRead, "Should have capped the read at the destination size")
assertEquals(10, destination[0])
assertEquals(30, destination[2])
}

@Test
fun testReadRespectsDestinationOffsetAndCapsRemainingSpace() {
val data = byteArrayOf(9, 9, 9, 9, 9)
val stream = ByteArrayInputStream(data)
val destination = ByteArray(10)

// Start at index 8. Only 2 slots (8, 9) are left in the destination.
// We ask for 5 bytes, but should only safely get 2.
val bytesRead = stream.read(destination, destinationOffset = 8, kapacity = 5.byte)

assertEquals(2, bytesRead)
assertEquals(9, destination[8])
assertEquals(9, destination[9])
}

@Test
fun testReadReturnsZeroForNegativeKapacity() {
val stream = ByteArrayInputStream(byteArrayOf(1))
val destination = ByteArray(5)

val readBytes = stream.read(destination, kapacity = (-1).byte)
assertEquals(0, readBytes)
}

@Test
fun testReadThrowsExceptionForOutOfBoundsOffset() {
val stream = ByteArrayInputStream(byteArrayOf(1))
val destination = ByteArray(5)

assertFailsWith<IllegalArgumentException> {
// Offset is larger than the array size
stream.read(destination, destinationOffset = 6, kapacity = 1.byte)
}

assertFailsWith<IllegalArgumentException> {
// Negative offset
stream.read(destination, destinationOffset = -1, kapacity = 1.byte)
}
}

// --- Tests for readKapacity() ---

@Test
fun testReadKapacityReturnsValidKapacityOnSuccess() {
val data = byteArrayOf(100, 101, 102)
val stream = ByteArrayInputStream(data)
val destination = ByteArray(5)

val kapacityRead = stream.readKapacity(destination, kapacity = 2.byte)

assertEquals(2.byte, kapacityRead)
assertEquals(100, destination[0])
assertEquals(101, destination[1])
}

@Test
fun testReadKapacityReturnsInvalidAtEndOfStream() {
val stream = ByteArrayInputStream(byteArrayOf())
val destination = ByteArray(5)

val kapacityRead = stream.readKapacity(destination, kapacity = 5.byte)

assertEquals(Kapacity.INVALID, kapacityRead)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.github.developrofthings.kapacity.io

import io.github.developrofthings.kapacity.byte
import java.io.ByteArrayOutputStream
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals

class OutputStreamKapacityTest {
@Test
fun testOutputStreamWrite_exactFit() {
val stream = ByteArrayOutputStream()
val source = byteArrayOf(1, 2, 3, 4, 5)

stream.write(source = source, kapacity = 5.byte)

assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), stream.toByteArray())
}

@Test
fun testOutputStreamWrite_limitedByKapacity() {
val stream = ByteArrayOutputStream()
val source = byteArrayOf(1, 2, 3, 4, 5)

// Source has 5 bytes, but we strictly limit the write to 3 bytes
stream.write(source = source, kapacity = 3.byte)

assertContentEquals(byteArrayOf(1, 2, 3), stream.toByteArray())
}

@Test
fun testOutputStreamWrite_limitedBySourceSpace() {
val stream = ByteArrayOutputStream()
val source = byteArrayOf(1, 2, 3)

// We ask to write 5 bytes, but the source array only has 3
stream.write(source = source, kapacity = 5.byte)

// Should safely clamp and write only the 3 available bytes without crashing
assertContentEquals(byteArrayOf(1, 2, 3), stream.toByteArray())
}

@Test
fun testOutputStreamWrite_withSourceOffset() {
val stream = ByteArrayOutputStream()
val source = byteArrayOf(9, 8, 7, 6, 5)

// Skip the first 2 bytes. We ask for 10 bytes, but only 3 remain in the array.
stream.write(
source = source,
sourceOffset = 2,
kapacity = 10.byte
)

// Should clamp to the remaining bytes after the offset
assertContentEquals(byteArrayOf(7, 6, 5), stream.toByteArray())
}

@Test
fun testOutputStreamWrite_zeroKapacityEarlyExit() {
val stream = ByteArrayOutputStream()
val source = byteArrayOf(1, 2, 3)

stream.write(source = source, kapacity = 0.byte)

assertEquals(0, stream.toByteArray().size, "Stream should remain completely empty")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@

package io.github.developrofthings.kapacity.io

import io.github.developrofthings.kapacity.ExperimentalKapacityApi
import io.github.developrofthings.kapacity.InternalKapacityApi
import io.github.developrofthings.kapacity.Kapacity
import io.github.developrofthings.kapacity.byte
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.nio.ByteBuffer
import java.nio.file.Path
import kotlin.io.path.fileSize
Expand Down Expand Up @@ -123,4 +127,119 @@ fun ByteBuffer.put(
/* offset = */ sourceOffset,
/* length = */ safeLength,
)
}


/**
* Returns an estimate of the number of bytes that can be read (or skipped over) from this
* [InputStream] without blocking, safely wrapped as a [Kapacity] instance.
*
* **Important Note on Java IO:** This property directly delegates to [InputStream.available].
* It represents the number of bytes currently buffered locally or immediately accessible.
* It does **not** represent the total remaining size of the stream or file. You should never
* use this property to allocate a buffer intended to hold the entire contents of a network stream.
*
* @return The estimated number of non-blocking bytes currently available, represented as [Kapacity].
* @throws java.io.IOException If an I/O error occurs while checking the underlying stream.
*/
val InputStream.available: Kapacity get() = available().byte

/**
* Reads up to the specified [kapacity] of bytes from this input stream into the given [destination] array.
*
* This function safely guards against buffer overflows. It automatically calculates the available
* space in the [destination] array starting from the [destinationOffset] and ensures that the number
* of bytes read does not exceed this available space, even if the requested [kapacity] is larger.
*
* @param destination The byte array to which data is written.
* @param destinationOffset The starting offset in the [destination] array where the data will be written. Defaults to 0.
* @param kapacity The maximum number of bytes to read from the stream.
* @return The total number of bytes read into the buffer, or `-1` if there is no more data because the end of the stream has been reached.
* @throws IllegalArgumentException If [destinationOffset] is outside the bounds of the [destination] array,
* or if [kapacity] represents a negative value.
*/
fun InputStream.read(
destination: ByteArray,
destinationOffset: Int = 0,
kapacity: Kapacity,
): Int {
require(destinationOffset in 0..destination.size) {
"destinationOffset ($destinationOffset) must be between 0 and ${destination.size}"
}
require(kapacity.rawBytes >= 0L) {
"Cannot read a negative kapacity: $kapacity"
}

val readLength = kapacity.rawBytesCoercedToIntRange
val availableSpace = (destination.size - destinationOffset)
val safeLength = minOf(
a = readLength,
b = availableSpace,
)
return this.read(
/* b = */ destination,
/* off = */ destinationOffset,
/* len = */ safeLength
)
}

/**
* Reads up to the specified [kapacity] of bytes from this input stream into the given [destination] array,
* returning the result as a [Kapacity] instance.
*
* Like its primitive counterpart, this function safely limits the read length to the available space
* in the [destination] array to prevent buffer overflows.
*
* @param destination The byte array to which data is written.
* @param destinationOffset The starting offset in the [destination] array where the data will be written. Defaults to 0.
* @param kapacity The maximum number of bytes to read from the stream.
* @return The total number of bytes read wrapped in a [Kapacity] instance, or [Kapacity.INVALID] if
* the end of the stream has been reached.
* @throws IllegalArgumentException If [destinationOffset] is outside the bounds of the [destination] array,
* or if [kapacity] represents a negative value.
*/
@ExperimentalKapacityApi
fun InputStream.readKapacity(
destination: ByteArray,
destinationOffset: Int = 0,
kapacity: Kapacity,
): Kapacity = this.read(
destination = destination,
destinationOffset = destinationOffset,
kapacity = kapacity
).takeIf { it >= 0 }?.byte ?: Kapacity.INVALID

/**
* Writes up to the specified [kapacity] of bytes from the [source] array to this output stream.
*
* This function blocks until the bytes are written or an exception is thrown.
* * **Safe Bounds:** The actual number of bytes written is safely clamped to prevent
* `IndexOutOfBoundsException`. The length will be the minimum of: the requested [kapacity] or
* the available data in the [source] array accounting for the [sourceOffset].
*
* @param source The data to write.
* @param sourceOffset The start offset in the [source] array from which to begin reading. Defaults to 0.
* @param kapacity The maximum number of bytes to write to the stream.
* @throws java.io.IOException If an I/O error occurs.
*/
fun OutputStream.write(
source: ByteArray,
sourceOffset: Int = 0,
kapacity: Kapacity,
) {
val writeLength = kapacity.rawBytesCoercedToIntRange
val availableSpace = (source.size - sourceOffset)
val safeLength = minOf(
a = writeLength,
b = availableSpace,
)

// Exit early if capacity is 0 or offsets are out of bounds
if (safeLength <= 0) return

this.write(
/* b = */ source,
/* off = */ sourceOffset,
/* len = */ safeLength
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.github.developrofthings.kapacity

@RequiresOptIn(
level = RequiresOptIn.Level.WARNING, // Or ERROR to be stricter
message = "This Kapacity API is experimental and subject to change."
)
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
annotation class ExperimentalKapacityApi
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,19 @@ value class Kapacity private constructor(val rawBytes: Long) : Comparable<Kapaci
override fun compareTo(other: Kapacity): Int = this.rawBytes.compareTo(other.rawBytes)

companion object {

/**
* A sentinel value representing an invalid, missing, or exhausted capacity.
*
* In the context of I/O operations, this is commonly returned to indicate that the
* end of a stream (EOF) has been reached, mirroring the `-1` result returned by
* standard Java API's.
*
* The underlying `rawBytes` value for this instance is `-1L`.
*/
@ExperimentalKapacityApi
val INVALID: Kapacity = Kapacity(rawBytes = -1L)

fun fromBytes(bytes: Long): Kapacity = Kapacity(
rawBytes = bytes.coerceAtLeast(
minimumValue = 0L
Expand Down
Loading