Skip to content

Commit b05c4b2

Browse files
committed
Add validation for exact and case-insensitive duplicate icon names across nested packs
1 parent 5880f86 commit b05c4b2

File tree

10 files changed

+476
-34
lines changed

10 files changed

+476
-34
lines changed

tools/cli/CHANGELOG.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66

77
- Add `--use-path-data-string` option to generate addPath with pathData strings instead of path builder calls
8+
- Add validation for exact duplicate icon names (e.g., `test-icon.svg` and `test_icon.svg` both produce `TestIcon.kt`)
89
- Add validation for case-insensitive duplicate icon names to prevent file overwrites on macOS/Windows
910

1011
### Removed
@@ -17,9 +18,6 @@
1718
### Fixed
1819

1920
- Fix parsing of Android system colors (e.g., `@android:color/white`) in XML parser
20-
- Fix batch processing where icons with case-insensitive duplicate names (e.g., `test-icon.svg` and `testicon.svg`)
21-
would overwrite each other on case-insensitive file systems (macOS APFS, Windows NTFS). CLI now terminates with a
22-
clear error message before attempting to write files
2321

2422
## [1.0.1] - 2025-11-20
2523

tools/cli/src/main/kotlin/io/github/composegears/valkyrie/cli/command/SvgXmlToImageVectorCommand.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,21 @@ private fun svgXml2ImageVector(
218218
)
219219
}
220220

221+
// Check for exact duplicates
222+
val exactDuplicates = iconNames
223+
.groupBy { it }
224+
.filter { it.value.size > 1 }
225+
.keys
226+
.toList()
227+
228+
if (exactDuplicates.isNotEmpty()) {
229+
outputError(
230+
"Found duplicate icon names: ${exactDuplicates.joinToString(", ")}. " +
231+
"Each icon must have a unique name. " +
232+
"Please rename the source files to avoid duplicates.",
233+
)
234+
}
235+
221236
// Check for case-insensitive duplicates that would cause file overwrites on case-insensitive file systems
222237
val caseInsensitiveDuplicates = iconNames
223238
.groupBy { it.lowercase() }

tools/cli/src/test/kotlin/io/github/composegears/valkyrie/cli/command/SvgXmlToImageVectorCommandTest.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,42 @@ class SvgXmlToImageVectorCommandTest {
175175
assertThat(exception.message).isEqualTo(mockMessage)
176176
verify { outputError(mockMessage) }
177177
}
178+
179+
@Test
180+
fun `should throw error for exact duplicate icon names`() {
181+
val mockMessage = "Found duplicate icon names: TestIcon. " +
182+
"Each icon must have a unique name. " +
183+
"Please rename the source files to avoid duplicates."
184+
185+
mockkStatic(::outputError)
186+
every { outputError(mockMessage) } answers { error(mockMessage) }
187+
188+
val tempDir = createTempDirectory()
189+
190+
val svgContent = """
191+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
192+
<path d="M0 0h24v24H0z" fill="none"/>
193+
</svg>
194+
""".trimIndent()
195+
196+
// Create two SVG files that produce the exact same icon name:
197+
// test-icon.svg -> TestIcon.kt
198+
// test_icon.svg -> TestIcon.kt (same result!)
199+
tempDir.resolve("test-icon.svg").toFile().writeText(svgContent)
200+
tempDir.resolve("test_icon.svg").toFile().writeText(svgContent)
201+
202+
val exception = assertFailsWith<IllegalStateException> {
203+
SvgXmlToImageVectorCommand().test(
204+
"--input-path",
205+
tempDir.absolutePathString(),
206+
"--output-path",
207+
createTempDirectory().absolutePathString(),
208+
"--package-name",
209+
"com.example",
210+
)
211+
}
212+
213+
assertThat(exception.message).isEqualTo(mockMessage)
214+
verify { outputError(mockMessage) }
215+
}
178216
}

tools/gradle-plugin/CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
- Add `usePathDataString` configuration option in `imageVector` block to generate addPath with pathData strings instead
88
of path builder calls
9+
- Add validation for exact duplicate icon names (e.g., `test-icon.svg` and `test_icon.svg` both produce `TestIcon.kt`)
910
- Add validation for case-insensitive duplicate icon names to prevent file overwrites on macOS/Windows
11+
- Add nested pack aware validation that correctly handles `useFlatPackage` mode - when enabled, duplicates are detected
12+
across all nested packs since they write to the same output folder
1013

1114
### Removed
1215

@@ -18,9 +21,6 @@
1821
### Fixed
1922

2023
- Fix parsing of Android system colors (e.g., `@android:color/white`) in XML parser
21-
- Fix build task where icons with case-insensitive duplicate names (e.g., `test-icon.svg` and `testicon.svg`) would
22-
overwrite each other on case-insensitive file systems (macOS APFS, Windows NTFS). Build now fails with a clear error
23-
message indicating the conflicting icon names
2424

2525
## [0.3.0] - 2025-12-11
2626

tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/internal/task/GenerateImageVectorsTask.kt

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -116,22 +116,9 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
116116
)
117117
}
118118

119-
// Check for case-insensitive duplicates that would cause file overwrites on case-insensitive file systems
120-
val caseInsensitiveDuplicates = iconNames
121-
.groupBy { it.lowercase() }
122-
.filter { it.value.size > 1 && it.value.distinct().size > 1 }
123-
.values
124-
.flatten()
125-
.distinct()
119+
// Check for duplicates with nested pack awareness
120+
validateDuplicates(iconFiles.files.toList(), iconNames)
126121

127-
if (caseInsensitiveDuplicates.isNotEmpty()) {
128-
throw GradleException(
129-
"Found icon names that would collide on case-insensitive file systems (macOS/Windows): " +
130-
"${caseInsensitiveDuplicates.joinToString(", ")}. " +
131-
"These icons would overwrite each other during generation. " +
132-
"Please rename the source files to avoid case-insensitive duplicates.",
133-
)
134-
}
135122

136123
if (iconPack.isPresent && iconPack.get().targetSourceSet.get() == sourceSet.get()) {
137124
generateIconPack(outputDirectory = outputDirectory)
@@ -408,4 +395,106 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
408395

409396
return null
410397
}
398+
399+
private fun validateDuplicates(files: List<File>, iconNames: List<String>) {
400+
if (iconPack.isPresent) {
401+
val pack = iconPack.get()
402+
val nestedPacks = pack.nestedPacks.get()
403+
val useFlatPackage = pack.useFlatPackage.get()
404+
405+
if (nestedPacks.isNotEmpty()) {
406+
// Build map: file -> nested pack name
407+
val sourceFolderToNestedPack = nestedPacks.associateBy { it.sourceFolder.get() }
408+
val fileToNestedPack = files.associateWith { file ->
409+
sourceFolderToNestedPack[file.parentFile.name]?.name?.get()
410+
}
411+
412+
// Group by nested pack (or single group if useFlatPackage)
413+
val iconsByPack = files.groupBy { file ->
414+
if (useFlatPackage) {
415+
pack.name.get() // All in same pack when flat
416+
} else {
417+
val nestedPackName = fileToNestedPack[file]
418+
if (nestedPackName != null) "${pack.name.get()}.$nestedPackName" else pack.name.get()
419+
}
420+
}
421+
422+
iconsByPack.forEach { (packIdentifier, filesInPack) ->
423+
val names = filesInPack.map { IconNameFormatter.format(name = it.name) }
424+
425+
// Check exact duplicates within this pack/group
426+
val exactDuplicates = names
427+
.groupBy { it }
428+
.filter { it.value.size > 1 }
429+
.keys
430+
.toList()
431+
432+
if (exactDuplicates.isNotEmpty()) {
433+
throw GradleException(
434+
"Found duplicate icon names in \"$packIdentifier\": ${exactDuplicates.joinToString(", ")}. " +
435+
"Each icon must have a unique name. " +
436+
"Please rename the source files to avoid duplicates.",
437+
)
438+
}
439+
440+
// Check case-insensitive duplicates within this pack/group
441+
val caseInsensitiveDuplicates = names
442+
.groupBy { it.lowercase() }
443+
.filter { it.value.size > 1 && it.value.distinct().size > 1 }
444+
.values
445+
.flatten()
446+
.distinct()
447+
448+
if (caseInsensitiveDuplicates.isNotEmpty()) {
449+
throw GradleException(
450+
"Found icon names that would collide on case-insensitive file systems (macOS/Windows) in \"$packIdentifier\": " +
451+
"${caseInsensitiveDuplicates.joinToString(", ")}. " +
452+
"These icons would overwrite each other during generation. " +
453+
"Please rename the source files to avoid case-insensitive duplicates.",
454+
)
455+
}
456+
}
457+
} else {
458+
// Single pack - check all files together
459+
checkDuplicatesInIconNames(iconNames, "icon pack \"${pack.name.get()}\"")
460+
}
461+
} else {
462+
// No icon pack - check all files together
463+
checkDuplicatesInIconNames(iconNames, "package \"${packageName.get()}\"")
464+
}
465+
}
466+
467+
private fun checkDuplicatesInIconNames(names: List<String>, context: String) {
468+
// Check exact duplicates
469+
val exactDuplicates = names
470+
.groupBy { it }
471+
.filter { it.value.size > 1 }
472+
.keys
473+
.toList()
474+
475+
if (exactDuplicates.isNotEmpty()) {
476+
throw GradleException(
477+
"Found duplicate icon names in $context: ${exactDuplicates.joinToString(", ")}. " +
478+
"Each icon must have a unique name. " +
479+
"Please rename the source files to avoid duplicates.",
480+
)
481+
}
482+
483+
// Check case-insensitive duplicates
484+
val caseInsensitiveDuplicates = names
485+
.groupBy { it.lowercase() }
486+
.filter { it.value.size > 1 && it.value.distinct().size > 1 }
487+
.values
488+
.flatten()
489+
.distinct()
490+
491+
if (caseInsensitiveDuplicates.isNotEmpty()) {
492+
throw GradleException(
493+
"Found icon names that would collide on case-insensitive file systems (macOS/Windows) in $context: " +
494+
"${caseInsensitiveDuplicates.joinToString(", ")}. " +
495+
"These icons would overwrite each other during generation. " +
496+
"Please rename the source files to avoid case-insensitive duplicates.",
497+
)
498+
}
499+
}
411500
}

0 commit comments

Comments
 (0)