Skip to content

Commit 0eaafdd

Browse files
committed
Avoid Desktop save overwrites
1 parent 8678fe1 commit 0eaafdd

3 files changed

Lines changed: 89 additions & 7 deletions

File tree

desktopApp/src/main/kotlin/me/rosuh/easywatermark/desktop/DesktopWindow.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ fun launchDesktopWindow() = application {
328328
picked = LastImage(bytes, file.path)
329329
// S4d-217: write the real save to the user output dir (not the build/ default).
330330
val fmt = userConfigRepo.userPreferences.first().outputFormat
331-
val out = File(outputDir, DesktopSaveDecision.defaultOutputFileName(fmt))
331+
val out = DesktopSaveDecision.resolveUniqueOutputFile(outputDir, fmt)
332332
val o = DesktopWatermarkFlow.runSaveFlow(
333333
repo, editor, userConfigRepo, inputBytes = bytes, inputLabel = file.path,
334334
outputFile = out,
@@ -781,7 +781,7 @@ fun launchDesktopWindow() = application {
781781
try {
782782
// S4d-217: write the real save to the user output dir (not the build/ default).
783783
val fmt = userConfigRepo.userPreferences.first().outputFormat
784-
val out = File(outputDir, DesktopSaveDecision.defaultOutputFileName(fmt))
784+
val out = DesktopSaveDecision.resolveUniqueOutputFile(outputDir, fmt)
785785
val o = if (current != null) {
786786
DesktopWatermarkFlow.runSaveFlow(
787787
repo, editor, userConfigRepo,
@@ -886,7 +886,7 @@ fun launchDesktopWindow() = application {
886886
picked = LastImage(bytes, selected.path)
887887
// S4d-217: write the real save to the user output dir (not the build/ default).
888888
val fmt = userConfigRepo.userPreferences.first().outputFormat
889-
val out = File(outputDir, DesktopSaveDecision.defaultOutputFileName(fmt))
889+
val out = DesktopSaveDecision.resolveUniqueOutputFile(outputDir, fmt)
890890
val o = DesktopWatermarkFlow.runSaveFlow(
891891
repo, editor, userConfigRepo, inputBytes = bytes, inputLabel = selected.path,
892892
outputFile = out,

shared/src/desktopMain/kotlin/me/rosuh/easywatermark/render/DesktopSaveDecision.kt

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@ package me.rosuh.easywatermark.render
22

33
import me.rosuh.easywatermark.data.model.ImageFormat
44
import me.rosuh.easywatermark.data.model.WatermarkMode
5+
import java.io.File
56

67
/**
7-
* S4d-139: the **pure** Desktop save-flow decision, extracted from
8+
* S4d-139 / S4d-222: the Desktop save-flow decision seam, extracted from
89
* `DesktopWatermarkFlow.runSaveFlow` (`:desktopApp`) so it is unit-testable in `:shared:desktopTest`
910
* — which cannot see `:desktopApp`. This is the smallest runtime-harness seam for the Desktop flow glue
1011
* (S4d-138 Q4), with **no new dependency** and **no behavior change**.
1112
*
12-
* It contains ONLY pure decisions (no IO): which render path, the default output filename for a format,
13+
* Most decisions here are pure (no IO): which render path, the default output filename for a format,
1314
* and whether the caller supplied input bytes. DataStore reads, the icon FILE-existence check, the
1415
* `DesktopWatermarkComposer` calls, and the disk write all stay in `runSaveFlow`.
1516
*
17+
* S4d-222 adds [resolveUniqueOutputFile], a desktopMain filesystem helper that performs existence
18+
* checks only and does not create, write, or delete files.
19+
*
1620
* The Image-mode blank-icon loud-fail message is moved here **verbatim** from the inline `require` it
1721
* replaces, so the flow's behavior is byte-for-byte the same (the missing-FILE check, which is IO,
1822
* stays in the flow and keeps its own message). See `DesktopSaveDecisionTest`.
@@ -51,6 +55,31 @@ object DesktopSaveDecision {
5155
fun defaultOutputFileName(format: ImageFormat): String =
5256
"watermarked." + format.fileExtension
5357

58+
/**
59+
* Returns a file in [dir] whose name does not collide with an existing file.
60+
*
61+
* Base name is `watermarked.<format.fileExtension>`. If that file already exists, the smallest
62+
* available suffix `n >= 1` is chosen: `watermarked_n.<ext>`. The helper performs filesystem
63+
* existence checks only and does not create, write, or delete files.
64+
*
65+
* Examples:
66+
* - empty dir → `watermarked.jpg`
67+
* - base exists → `watermarked_1.jpg`
68+
* - base + `_1` exist → `watermarked_2.jpg`
69+
* - base + `_2` exist while `_1` is free → `watermarked_1.jpg`
70+
*/
71+
fun resolveUniqueOutputFile(dir: File, format: ImageFormat): File {
72+
val ext = format.fileExtension
73+
val base = File(dir, "watermarked.$ext")
74+
if (!base.exists()) return base
75+
var n = 1
76+
while (true) {
77+
val candidate = File(dir, "watermarked_${n}.$ext")
78+
if (!candidate.exists()) return candidate
79+
n++
80+
}
81+
}
82+
5483
/**
5584
* Whether the save composites over caller-provided bytes (`true`) or the generated fixture (`false`).
5685
* This is the pure form of the flow's `inputBytes ?: fixture` idiom — represented here so the rule is

shared/src/desktopTest/kotlin/me/rosuh/easywatermark/render/DesktopSaveDecisionTest.kt

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ package me.rosuh.easywatermark.render
22

33
import me.rosuh.easywatermark.data.model.ImageFormat
44
import me.rosuh.easywatermark.data.model.WatermarkMode
5+
import java.io.File
6+
import kotlin.io.path.createTempDirectory
57
import kotlin.test.Test
68
import kotlin.test.assertEquals
79
import kotlin.test.assertFailsWith
810
import kotlin.test.assertFalse
911
import kotlin.test.assertTrue
1012

1113
/**
12-
* S4d-139: unit tests for the pure [DesktopSaveDecision] seam extracted from
14+
* S4d-139 / S4d-222: unit tests for the [DesktopSaveDecision] seam extracted from
1315
* `DesktopWatermarkFlow.runSaveFlow`. This is the runtime harness for the Desktop flow glue that
1416
* `:shared:desktopTest` can reach (the flow itself lives in `:desktopApp`, which has no test source set).
15-
* Pure decisions only — no IO, no rendering.
17+
*
18+
* Most tests exercise pure decisions (no IO, no rendering). S4d-222 adds tests for
19+
* [resolveUniqueOutputFile], which performs filesystem existence checks only and does not create,
20+
* write, or delete files.
1621
*/
1722
class DesktopSaveDecisionTest {
1823

@@ -56,4 +61,52 @@ class DesktopSaveDecisionTest {
5661
assertTrue(DesktopSaveDecision.usesCallerInput(ByteArray(0)), "even empty (non-null) bytes → caller input")
5762
assertFalse(DesktopSaveDecision.usesCallerInput(null), "null → fixture default")
5863
}
64+
65+
@Test
66+
fun resolve_unique_uses_base_name_when_dir_is_empty() {
67+
val dir = createTempDirectory().toFile()
68+
val result = DesktopSaveDecision.resolveUniqueOutputFile(dir, ImageFormat.JPEG)
69+
assertEquals(File(dir, "watermarked.jpg"), result)
70+
}
71+
72+
@Test
73+
fun resolve_unique_adds_suffix_when_base_exists() {
74+
val dir = createTempDirectory().toFile()
75+
File(dir, "watermarked.jpg").createNewFile()
76+
val result = DesktopSaveDecision.resolveUniqueOutputFile(dir, ImageFormat.JPEG)
77+
assertEquals(File(dir, "watermarked_1.jpg"), result)
78+
}
79+
80+
@Test
81+
fun resolve_unique_finds_smallest_available_suffix_with_multiple_collisions() {
82+
val dir = createTempDirectory().toFile()
83+
File(dir, "watermarked.jpg").createNewFile()
84+
File(dir, "watermarked_1.jpg").createNewFile()
85+
val result = DesktopSaveDecision.resolveUniqueOutputFile(dir, ImageFormat.JPEG)
86+
assertEquals(File(dir, "watermarked_2.jpg"), result)
87+
}
88+
89+
@Test
90+
fun resolve_unique_fills_numbering_gap_when_lower_suffix_is_free() {
91+
val dir = createTempDirectory().toFile()
92+
File(dir, "watermarked.jpg").createNewFile()
93+
File(dir, "watermarked_2.jpg").createNewFile()
94+
val result = DesktopSaveDecision.resolveUniqueOutputFile(dir, ImageFormat.JPEG)
95+
assertEquals(File(dir, "watermarked_1.jpg"), result)
96+
}
97+
98+
@Test
99+
fun resolve_unique_does_not_create_the_returned_file() {
100+
val dir = createTempDirectory().toFile()
101+
val result = DesktopSaveDecision.resolveUniqueOutputFile(dir, ImageFormat.PNG)
102+
assertFalse(result.exists(), "helper must not create the file")
103+
}
104+
105+
@Test
106+
fun resolve_unique_is_format_independent() {
107+
val dir = createTempDirectory().toFile()
108+
File(dir, "watermarked.jpg").createNewFile()
109+
val pngResult = DesktopSaveDecision.resolveUniqueOutputFile(dir, ImageFormat.PNG)
110+
assertEquals(File(dir, "watermarked.png"), pngResult)
111+
}
59112
}

0 commit comments

Comments
 (0)