Skip to content

Commit 85a9fd5

Browse files
committed
feat(core-file): add WriteMode to support append vs truncate
1 parent 270436b commit 85a9fd5

File tree

8 files changed

+193
-10
lines changed

8 files changed

+193
-10
lines changed

core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ import kotlinx.io.asSource
1414
class AndroidFileSystemManager(
1515
private val contentResolver: ContentResolver,
1616
) : FileSystemManager {
17-
override fun openSink(uri: Uri): RawSink? {
18-
// Use truncate/overwrite mode by default
19-
return contentResolver.openOutputStream(uri.toAndroidUri(), "wt")?.asSink()
17+
override fun openSink(uri: Uri, mode: WriteMode): RawSink? {
18+
// Map WriteMode to ContentResolver open modes: "wt" (truncate) or "wa" (append)
19+
val androidMode = when (mode) {
20+
WriteMode.Truncate -> "wt"
21+
WriteMode.Append -> "wa"
22+
}
23+
return contentResolver.openOutputStream(uri.toAndroidUri(), androidMode)?.asSink()
2024
}
2125

2226
override fun openSource(uri: Uri): RawSource? {

core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,82 @@ class AndroidFileSystemManagerTest {
5353
// Assert
5454
assertThat(result).isEqualTo(testText)
5555
}
56+
57+
@Test
58+
fun openSink_withAppend_shouldAppendToExistingContent() {
59+
// Arrange
60+
val tempFile = folder.newFile("tb-file-fs-test-android-append.txt")
61+
val uri: Uri = Uri.fromFile(tempFile)
62+
val initial = "Hello"
63+
val extra = " Android"
64+
65+
// Write initial content (truncate by default)
66+
run {
67+
val sink = checkNotNull(testSubject.openSink(uri.toKmpUri()))
68+
val buf = Buffer().apply { write(initial.encodeToByteArray()) }
69+
sink.write(buf, buf.size)
70+
sink.flush()
71+
sink.close()
72+
}
73+
74+
// Append extra content
75+
run {
76+
val sink = checkNotNull(testSubject.openSink(uri.toKmpUri(), WriteMode.Append))
77+
val buf = Buffer().apply { write(extra.encodeToByteArray()) }
78+
sink.write(buf, buf.size)
79+
sink.flush()
80+
sink.close()
81+
}
82+
83+
// Read back
84+
val source = checkNotNull(testSubject.openSource(uri.toKmpUri()))
85+
val readBuffer = Buffer()
86+
source.readAtMostTo(readBuffer, 1024)
87+
val bytes = ByteArray(readBuffer.size.toInt())
88+
repeat(bytes.size) { i -> bytes[i] = readBuffer.readByte() }
89+
val result = bytes.decodeToString()
90+
source.close()
91+
92+
// Assert
93+
assertThat(result).isEqualTo(initial + extra)
94+
}
95+
96+
@Test
97+
fun openSink_withTruncate_shouldOverwriteExistingContent() {
98+
// Arrange
99+
val tempFile = folder.newFile("tb-file-fs-test-android-truncate.txt")
100+
val uri: Uri = Uri.fromFile(tempFile)
101+
val first = "First"
102+
val second = "Second"
103+
104+
// Write first content
105+
run {
106+
val sink = checkNotNull(testSubject.openSink(uri.toKmpUri(), WriteMode.Truncate))
107+
val buf = Buffer().apply { write(first.encodeToByteArray()) }
108+
sink.write(buf, buf.size)
109+
sink.flush()
110+
sink.close()
111+
}
112+
113+
// Overwrite with second content
114+
run {
115+
val sink = checkNotNull(testSubject.openSink(uri.toKmpUri(), WriteMode.Truncate))
116+
val buf = Buffer().apply { write(second.encodeToByteArray()) }
117+
sink.write(buf, buf.size)
118+
sink.flush()
119+
sink.close()
120+
}
121+
122+
// Read back
123+
val source = checkNotNull(testSubject.openSource(uri.toKmpUri()))
124+
val readBuffer = Buffer()
125+
source.readAtMostTo(readBuffer, 1024)
126+
val bytes = ByteArray(readBuffer.size.toInt())
127+
repeat(bytes.size) { i -> bytes[i] = readBuffer.readByte() }
128+
val result = bytes.decodeToString()
129+
source.close()
130+
131+
// Assert
132+
assertThat(result).isEqualTo(second)
133+
}
56134
}

core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ interface FileSystemManager {
1111
/**
1212
* Opens a sink for writing to a URI.
1313
*
14-
* Implementations should open the destination for writing in overwrite/truncate mode.
14+
* Implementations must honor the requested [mode]:
15+
* - [WriteMode.Truncate]: overwrite existing content (truncate) or create if missing
16+
* - [WriteMode.Append]: append to existing content or create if missing
1517
*
1618
* @param uri The URI to open a sink for
19+
* @param mode The write mode (truncate/append), defaults to [WriteMode.Truncate]
1720
* @return A sink for writing to the URI, or null if the URI couldn't be opened
1821
*/
19-
fun openSink(uri: Uri): RawSink?
22+
fun openSink(uri: Uri, mode: WriteMode = WriteMode.Truncate): RawSink?
2023

2124
/**
2225
* Opens a source for reading from a URI.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package net.thunderbird.core.file
2+
3+
/**
4+
* Indicates how a sink should be opened for writing.
5+
*
6+
* - [Truncate]: Overwrite existing content or create if missing
7+
* - [Append]: Append to existing content or create if missing
8+
*/
9+
enum class WriteMode {
10+
Truncate,
11+
Append,
12+
}

core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.eygraber.uri.Uri
44
import kotlinx.io.Buffer
55
import net.thunderbird.core.file.FileOperationError
66
import net.thunderbird.core.file.FileSystemManager
7+
import net.thunderbird.core.file.WriteMode
78
import net.thunderbird.core.outcome.Outcome
89

910
/**
@@ -19,7 +20,7 @@ internal class CopyCommand(
1920
?: return Outcome.Failure(
2021
FileOperationError.Unavailable(sourceUri, "Unable to open source: $sourceUri"),
2122
)
22-
val sink = fs.openSink(destinationUri)
23+
val sink = fs.openSink(destinationUri, WriteMode.Truncate)
2324
?: return Outcome.Failure(
2425
FileOperationError.Unavailable(destinationUri, "Unable to open destination: $destinationUri"),
2526
)

core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ class FakeFileSystemManager : FileSystemManager {
1313

1414
private val storage = mutableMapOf<String, ByteArray>()
1515

16-
override fun openSink(uri: Uri): RawSink? {
16+
override fun openSink(uri: Uri, mode: WriteMode): RawSink? {
1717
val key = uri.toString()
1818
return object : RawSink {
19-
private val collected = mutableListOf<Byte>()
19+
private val collected = mutableListOf<Byte>().apply {
20+
if (mode == WriteMode.Append) {
21+
storage[key]?.forEach { add(it) }
22+
}
23+
}
2024

2125
override fun write(source: Buffer, byteCount: Long) {
2226
// Read exactly byteCount bytes from source and collect

core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import kotlinx.io.asSource
1414
* JVM implementation of [FileSystemManager] using java.io streams.
1515
*/
1616
class JvmFileSystemManager : FileSystemManager {
17-
override fun openSink(uri: Uri): RawSink? {
17+
override fun openSink(uri: Uri, mode: WriteMode): RawSink? {
1818
// Only support simple file paths for JVM implementation
1919
return try {
2020
val file = File(uri.toURI())
2121
// create parent directories if necessary
2222
file.parentFile?.mkdirs()
23-
val append = false // overwrite/truncate by default
23+
val append = when (mode) {
24+
WriteMode.Truncate -> false
25+
WriteMode.Append -> true
26+
}
2427
FileOutputStream(file, append).asSink()
2528
} catch (_: Throwable) {
2629
null

core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,82 @@ class JvmFileSystemManagerTest {
4444
// Assert
4545
assertThat(result).isEqualTo(testText)
4646
}
47+
48+
@Test
49+
fun `openSink with Append should append to existing content`() {
50+
// Arrange
51+
val tempFile: File = folder.newFile("tb-file-fs-append.txt")
52+
val uri = Uri.parse(tempFile.toURI().toString())
53+
val initial = "Hello"
54+
val extra = " World"
55+
56+
// Write initial content (truncate by default)
57+
run {
58+
val sink = checkNotNull(testSubject.openSink(uri))
59+
val buf = Buffer().apply { write(initial.encodeToByteArray()) }
60+
sink.write(buf, buf.size)
61+
sink.flush()
62+
sink.close()
63+
}
64+
65+
// Append extra content
66+
run {
67+
val sink = checkNotNull(testSubject.openSink(uri, WriteMode.Append))
68+
val buf = Buffer().apply { write(extra.encodeToByteArray()) }
69+
sink.write(buf, buf.size)
70+
sink.flush()
71+
sink.close()
72+
}
73+
74+
// Read back
75+
val source = checkNotNull(testSubject.openSource(uri))
76+
val readBuffer = Buffer()
77+
source.readAtMostTo(readBuffer, 1024)
78+
val bytes = ByteArray(readBuffer.size.toInt())
79+
repeat(bytes.size) { i -> bytes[i] = readBuffer.readByte() }
80+
val result = bytes.decodeToString()
81+
source.close()
82+
83+
// Assert
84+
assertThat(result).isEqualTo(initial + extra)
85+
}
86+
87+
@Test
88+
fun `openSink with Truncate should overwrite existing content`() {
89+
// Arrange
90+
val tempFile: File = folder.newFile("tb-file-fs-truncate.txt")
91+
val uri = Uri.parse(tempFile.toURI().toString())
92+
val first = "First"
93+
val second = "Second"
94+
95+
// Write first content
96+
run {
97+
val sink = checkNotNull(testSubject.openSink(uri, WriteMode.Truncate))
98+
val buf = Buffer().apply { write(first.encodeToByteArray()) }
99+
sink.write(buf, buf.size)
100+
sink.flush()
101+
sink.close()
102+
}
103+
104+
// Overwrite with second content
105+
run {
106+
val sink = checkNotNull(testSubject.openSink(uri, WriteMode.Truncate))
107+
val buf = Buffer().apply { write(second.encodeToByteArray()) }
108+
sink.write(buf, buf.size)
109+
sink.flush()
110+
sink.close()
111+
}
112+
113+
// Read back
114+
val source = checkNotNull(testSubject.openSource(uri))
115+
val readBuffer = Buffer()
116+
source.readAtMostTo(readBuffer, 1024)
117+
val bytes = ByteArray(readBuffer.size.toInt())
118+
repeat(bytes.size) { i -> bytes[i] = readBuffer.readByte() }
119+
val result = bytes.decodeToString()
120+
source.close()
121+
122+
// Assert
123+
assertThat(result).isEqualTo(second)
124+
}
47125
}

0 commit comments

Comments
 (0)