From 57927a3b2d366bd825c9476d4aa1205f6c810e8f Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:33:14 +0200 Subject: [PATCH 01/17] Add basic end-to-end tests for find-duplicates subcommand --- .../Contents.json | 6 +++ .../OtherColors/Contents.json | 6 +++ .../lavenderHex.colorset/Contents.json | 20 ++++++++++ .../palePurpleHex.colorset/Contents.json | 20 ++++++++++ .../caribbeanSeaHex.colorset/Contents.json | 20 ++++++++++ .../Contents.json | 20 ++++++++++ .../lavender.colorset/Contents.json | 20 ++++++++++ .../Contents.json | 38 +++++++++++++++++++ Tests/hexcodeTests/hexcodeEndToEndTests.swift | 31 +++++++++++++++ 9 files changed, 181 insertions(+) create mode 100644 Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/Contents.json create mode 100644 Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/Contents.json create mode 100644 Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/lavenderHex.colorset/Contents.json create mode 100644 Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/palePurpleHex.colorset/Contents.json create mode 100644 Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/caribbeanSeaHex.colorset/Contents.json create mode 100644 Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/darkSunflowerDuplicateHex.colorset/Contents.json create mode 100644 Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/lavender.colorset/Contents.json create mode 100644 Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/sunflowerDuplicateHex.colorset/Contents.json diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/lavenderHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/lavenderHex.colorset/Contents.json new file mode 100644 index 0000000..3e8a33b --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/lavenderHex.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFA", + "green" : "0xE6", + "red" : "0xE6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/palePurpleHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/palePurpleHex.colorset/Contents.json new file mode 100644 index 0000000..3e8a33b --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/OtherColors/palePurpleHex.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFA", + "green" : "0xE6", + "red" : "0xE6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/caribbeanSeaHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/caribbeanSeaHex.colorset/Contents.json new file mode 100644 index 0000000..327d63c --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/caribbeanSeaHex.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9D", + "green" : "0x81", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/darkSunflowerDuplicateHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/darkSunflowerDuplicateHex.colorset/Contents.json new file mode 100644 index 0000000..09404b3 --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/darkSunflowerDuplicateHex.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2F", + "green" : "0xEA", + "red" : "0xF4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/lavender.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/lavender.colorset/Contents.json new file mode 100644 index 0000000..9bbfd2e --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/lavender.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "250", + "green" : "230", + "red" : "230" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/sunflowerDuplicateHex.colorset/Contents.json b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/sunflowerDuplicateHex.colorset/Contents.json new file mode 100644 index 0000000..425a691 --- /dev/null +++ b/Tests/hexcodeTests/Resources/AssetsWithDuplicates.xcassets/sunflowerDuplicateHex.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2A", + "green" : "0xDE", + "red" : "0xE8" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2F", + "green" : "0xEA", + "red" : "0xF4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/hexcodeTests/hexcodeEndToEndTests.swift b/Tests/hexcodeTests/hexcodeEndToEndTests.swift index a699122..7e2216c 100644 --- a/Tests/hexcodeTests/hexcodeEndToEndTests.swift +++ b/Tests/hexcodeTests/hexcodeEndToEndTests.swift @@ -166,6 +166,37 @@ final class HexcodeEndToEndTests: XCTestCase { XCTAssertEqual(output, "") XCTAssert(error.contains("Error: Color hex contains invalid symbols\n")) } + + func test_hexcode_findDuplicates_withMultipleDuplicates_findsAllDuplicates() throws { + let arguments = ["find-duplicates", "--directory=\(resourcePath)/AssetsWithDuplicates.xcassets"] + + let (output, error) = try runHexcode(arguments: arguments) + + XCTAssertEqual( + output, + """ + #E6E6FA lavender + #E6E6FA OtherColors/lavenderHex + #E6E6FA OtherColors/palePurpleHex + -- + #F4EA2F darkSunflowerDuplicateHex + #F4EA2F sunflowerDuplicateHex (Dark)\n + """ + ) + XCTAssertEqual(error, "") + } + + func test_hexcode_findDuplicates_inDicectoryWithoutDuplicates_outputsNoDuplicatesFoundMessage() throws { + // Given + let arguments = ["find-duplicates", "--directory=\(resourcePath)/AssetsInSubdirectories.xcassets"] + + // When + let (output, error) = try runHexcode(arguments: arguments) + + // Then + XCTAssertEqual(output, "No duplicates found\n") + XCTAssertEqual(error, "") + } } // MARK: - Private From c91511255056cae40eb523a247a902bddfd911f8 Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Wed, 25 Dec 2024 22:07:52 +0200 Subject: [PATCH 02/17] Bump argument parser version to 1.5.0 --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 24b4f86..c36ddcf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "46989693916f56d1186bd59ac15124caef896560", - "version" : "1.3.1" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" } }, { diff --git a/Package.swift b/Package.swift index 06f553e..6b36696 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .macOS(.v13), ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), .package(url: "https://github.com/artem-y/swifty-test-assertions.git", from: "0.1.1"), ], targets: [ From f872250391091c9a8fb8a9684302f7fbf3835f4e Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Fri, 27 Dec 2024 18:54:15 +0200 Subject: [PATCH 03/17] Split main command into subcommands, add empty find-duplicates command --- .../FindColor.swift} | 12 +++++----- Sources/hexcode/Commands/FindDuplicates.swift | 15 +++++++++++++ Sources/hexcode/Commands/Hexcode.swift | 20 +++++++++++++++++ Sources/hexcode/HexcodeApp.swift | 19 ++++++++++++---- Tests/hexcodeTests/HexcodeAppTests.swift | 22 +++++++++---------- 5 files changed, 66 insertions(+), 22 deletions(-) rename Sources/hexcode/{Hexcode.swift => Commands/FindColor.swift} (64%) create mode 100644 Sources/hexcode/Commands/FindDuplicates.swift create mode 100644 Sources/hexcode/Commands/Hexcode.swift diff --git a/Sources/hexcode/Hexcode.swift b/Sources/hexcode/Commands/FindColor.swift similarity index 64% rename from Sources/hexcode/Hexcode.swift rename to Sources/hexcode/Commands/FindColor.swift index 7189d36..2268cfc 100644 --- a/Sources/hexcode/Hexcode.swift +++ b/Sources/hexcode/Commands/FindColor.swift @@ -1,15 +1,13 @@ import ArgumentParser -@main -struct Hexcode: AsyncParsableCommand { +struct FindColor: AsyncParsableCommand { static let configuration = CommandConfiguration( - commandName: "hexcode", + commandName: "find-color", abstract: """ - hexcode is a tool that finds Xcode color assets \ + Default subcommand that finds Xcode color assets \ by their hexadecimal codes. - """, - version: "hexcode 0.1.1" + """ ) @Argument @@ -29,6 +27,6 @@ struct Hexcode: AsyncParsableCommand { } func run() async throws { - try await HexcodeApp().run(colorHex: colorHex, in: directory) + try await HexcodeApp().runFindColor(colorHex: colorHex, in: directory) } } diff --git a/Sources/hexcode/Commands/FindDuplicates.swift b/Sources/hexcode/Commands/FindDuplicates.swift new file mode 100644 index 0000000..5086a18 --- /dev/null +++ b/Sources/hexcode/Commands/FindDuplicates.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct FindDuplicates: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "find-duplicates", + abstract: "Finds duplicate Xcode color assets." + ) + + @Option + var directory: String? + + func run() async throws { + try await HexcodeApp().runFindDuplicates(in: directory) + } +} diff --git a/Sources/hexcode/Commands/Hexcode.swift b/Sources/hexcode/Commands/Hexcode.swift new file mode 100644 index 0000000..9c10924 --- /dev/null +++ b/Sources/hexcode/Commands/Hexcode.swift @@ -0,0 +1,20 @@ +import ArgumentParser + +@main +struct Hexcode: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "hexcode", + abstract: """ + hexcode is a tool that finds Xcode color assets \ + by their hexadecimal codes. + """, + usage: "hexcode [--directory ]", + version: "hexcode 0.1.1", + subcommands: [ + FindColor.self, + FindDuplicates.self, + ], + defaultSubcommand: FindColor.self + ) +} diff --git a/Sources/hexcode/HexcodeApp.swift b/Sources/hexcode/HexcodeApp.swift index 45f3020..b6049bd 100644 --- a/Sources/hexcode/HexcodeApp.swift +++ b/Sources/hexcode/HexcodeApp.swift @@ -21,11 +21,12 @@ final class HexcodeApp { self.assetCollector = assetCollector } - /// Entry point for `hexcode` app logic. - /// - parameter colorHex: Raw input argument for hexadecimal color code. - /// - parameter directory: Optional custom directory from user input. Defaults to current directory. + /// Entry point for the default `find-color` subcommand logic. + /// - Parameters: + /// - colorHex: Raw input argument for hexadecimal color code. + /// - directory: Optional custom directory from user input. Defaults to current directory. /// - throws: All unhandled errors that can be thrown out to standard output. - func run(colorHex: String, in directory: String? = nil) async throws { + func runFindColor(colorHex: String, in directory: String? = nil) async throws { let directory = directory ?? fileManager.currentDirectoryPath let colorAssets = try await assetCollector.collectAssets(in: directory) let foundColors = colorFinder.find(colorHex, in: colorAssets) @@ -37,4 +38,14 @@ final class HexcodeApp { foundColors.forEach { output($0) } } + + + /// Entry point for the `find-duplicates` subcommand logic. + /// - Parameter directory: Optional custom directory from user input. Defaults to current directory. + /// - throws: All unhandled errors that can be thrown out to standard output. + func runFindDuplicates(in directory: String? = nil) async throws { + let directory = directory ?? fileManager.currentDirectoryPath + // TODO: Ipmlememnt underlying logic of the "find-duplicates" command + output("No duplicates found") + } } diff --git a/Tests/hexcodeTests/HexcodeAppTests.swift b/Tests/hexcodeTests/HexcodeAppTests.swift index 620a1da..2bb3345 100644 --- a/Tests/hexcodeTests/HexcodeAppTests.swift +++ b/Tests/hexcodeTests/HexcodeAppTests.swift @@ -43,7 +43,7 @@ final class HexcodeAppTests: XCTestCase { mocks.fileManager.results.currentDirectoryPath = currentDirectory // When - try await sut.run(colorHex: "") + try await sut.runFindColor(colorHex: "") // Then XCTAssertEqual(mocks.fileManager.calls, [.getCurrentDirectoryPath]) @@ -55,7 +55,7 @@ final class HexcodeAppTests: XCTestCase { let searchDirectory = "/searchDirectory" // When - try await sut.run(colorHex: "", in: searchDirectory) + try await sut.runFindColor(colorHex: "", in: searchDirectory) // Then XCTAssertEqual(mocks.assetCollector.calls, [.collectAssetsIn(directory: searchDirectory)]) @@ -66,7 +66,7 @@ final class HexcodeAppTests: XCTestCase { mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.notADirectory) await AssertAsync( - try await sut.run(colorHex: blackHexStub), // When + try await sut.runFindColor(colorHex: blackHexStub), // When throwsError: AssetCollector.Error.notADirectory // Then ) } @@ -76,7 +76,7 @@ final class HexcodeAppTests: XCTestCase { mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.directoryNotFound) await AssertAsync( - try await sut.run(colorHex: blackHexStub), // When + try await sut.runFindColor(colorHex: blackHexStub), // When throwsError: AssetCollector.Error.directoryNotFound // Then ) } @@ -86,7 +86,7 @@ final class HexcodeAppTests: XCTestCase { mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.notADirectory) // When - try? await sut.run(colorHex: blackHexStub) + try? await sut.runFindColor(colorHex: blackHexStub) // Then AssertEmpty(mocks.colorFinder.calls) @@ -98,7 +98,7 @@ final class HexcodeAppTests: XCTestCase { mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.directoryNotFound) // When - try? await sut.run(colorHex: blackHexStub) + try? await sut.runFindColor(colorHex: blackHexStub) // Then AssertEmpty(mocks.colorFinder.calls) @@ -116,7 +116,7 @@ final class HexcodeAppTests: XCTestCase { mocks.assetCollector.results.collectAssets = .success(expectedColorSets) // When - try await sut.run(colorHex: colorHex) + try await sut.runFindColor(colorHex: colorHex) // Then XCTAssertEqual( @@ -131,7 +131,7 @@ final class HexcodeAppTests: XCTestCase { mocks.assetCollector.results.collectAssets = .success([]) // When - try await sut.run(colorHex: colorHex) + try await sut.runFindColor(colorHex: colorHex) // Then XCTAssertEqual( @@ -146,7 +146,7 @@ final class HexcodeAppTests: XCTestCase { mocks.colorFinder.results.find = [expectedOutput] // When - try await sut.run(colorHex: "") + try await sut.runFindColor(colorHex: "") // Then XCTAssertEqual(mocks.outputs, [expectedOutput]) @@ -158,7 +158,7 @@ final class HexcodeAppTests: XCTestCase { mocks.colorFinder.results.find = expectedOutputs // When - try await sut.run(colorHex: "") + try await sut.runFindColor(colorHex: "") // Then XCTAssertEqual(mocks.outputs, expectedOutputs) @@ -169,7 +169,7 @@ final class HexcodeAppTests: XCTestCase { mocks.colorFinder.results.find = [] // When - try await sut.run(colorHex: blackHexStub) + try await sut.runFindColor(colorHex: blackHexStub) // Then XCTAssertEqual(mocks.outputs, ["No \(blackHexStub) color found"]) From 173b664615e4224f7c9feb4045cacde904d1d28f Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Fri, 27 Dec 2024 20:16:18 +0200 Subject: [PATCH 04/17] Connect color finder to the search for duplicates --- Sources/hexcode/Controllers/ColorFinder.swift | 10 ++++++++++ Sources/hexcode/HexcodeApp.swift | 18 ++++++++++++++++-- Tests/hexcodeTests/Mocks/ColorFinderMock.swift | 7 +++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/hexcode/Controllers/ColorFinder.swift b/Sources/hexcode/Controllers/ColorFinder.swift index 2fffed7..f9c9f39 100644 --- a/Sources/hexcode/Controllers/ColorFinder.swift +++ b/Sources/hexcode/Controllers/ColorFinder.swift @@ -5,6 +5,12 @@ protocol ColorFinding { /// - parameter colorSets: Color sets to check for matching hex color code. /// - returns: Names of color sets with matching colors. Empty if none found. func find(_ hex: String, in colorSets: [NamedColorSet]) -> [String] + + + /// Searches the collection of named color sets for colors matching the same hex equivalent. + /// - Parameter colorSets: Color sets to check for duplicate hex values. + /// - Returns: Hexadecimal color codes with arrays of matching color duplicates. Empty if none found. + func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] } final class ColorFinder: ColorFinding { @@ -28,6 +34,10 @@ final class ColorFinder: ColorFinding { return namedSet.name + " (\(joined(appearances)))" } } + + func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] { + [:] + } } // MARK: - Private diff --git a/Sources/hexcode/HexcodeApp.swift b/Sources/hexcode/HexcodeApp.swift index b6049bd..30f6925 100644 --- a/Sources/hexcode/HexcodeApp.swift +++ b/Sources/hexcode/HexcodeApp.swift @@ -45,7 +45,21 @@ final class HexcodeApp { /// - throws: All unhandled errors that can be thrown out to standard output. func runFindDuplicates(in directory: String? = nil) async throws { let directory = directory ?? fileManager.currentDirectoryPath - // TODO: Ipmlememnt underlying logic of the "find-duplicates" command - output("No duplicates found") + let colorAssets = try await assetCollector.collectAssets(in: directory) + let foundDuplicates = colorFinder.findDuplicates(in: colorAssets) + + if foundDuplicates.isEmpty { + output("No duplicates found") + return + } + + foundDuplicates + .sorted { $0.key < $1.key } + .forEach { duplicate in + + duplicate.value.forEach { color in + output("#\(duplicate.key) \(color)") + } + } } } diff --git a/Tests/hexcodeTests/Mocks/ColorFinderMock.swift b/Tests/hexcodeTests/Mocks/ColorFinderMock.swift index d46e6b6..3ddaa5e 100644 --- a/Tests/hexcodeTests/Mocks/ColorFinderMock.swift +++ b/Tests/hexcodeTests/Mocks/ColorFinderMock.swift @@ -3,10 +3,12 @@ final class ColorFinderMock { enum Call: Equatable { case find(hex: String, colorSets: [NamedColorSet]) + case findDuplicates(in: [NamedColorSet]) } struct CallResults { var find: [String] = [] + var findDuplicates: [String: [String]] = [:] } private(set) var calls: [Call] = [] @@ -25,4 +27,9 @@ extension ColorFinderMock: ColorFinding { calls.append(.find(hex: hex, colorSets: colorSets)) return results.find } + + func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] { + calls.append(.findDuplicates(in: colorSets)) + return results.findDuplicates + } } From 9ed5a66c612ad03785a80331978d0f4425c92206 Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Fri, 27 Dec 2024 20:38:47 +0200 Subject: [PATCH 05/17] Change app version to feature branch name --- Sources/hexcode/Commands/Hexcode.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/hexcode/Commands/Hexcode.swift b/Sources/hexcode/Commands/Hexcode.swift index 9c10924..a7572e7 100644 --- a/Sources/hexcode/Commands/Hexcode.swift +++ b/Sources/hexcode/Commands/Hexcode.swift @@ -10,7 +10,7 @@ struct Hexcode: AsyncParsableCommand { by their hexadecimal codes. """, usage: "hexcode [--directory ]", - version: "hexcode 0.1.1", + version: "hexcode feature/find-duplicates", subcommands: [ FindColor.self, FindDuplicates.self, From 20bb539245976a5eff11f1f59656a344d82258df Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Sun, 29 Dec 2024 14:54:23 +0200 Subject: [PATCH 06/17] Implement logic for finding duplicates --- Sources/hexcode/Controllers/ColorFinder.swift | 60 ++++++++++++++++++- Tests/hexcodeTests/ColorFinderTests.swift | 25 ++++++++ Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift | 1 + .../Stubs/NamedColorSet+Stubs.swift | 5 ++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/Sources/hexcode/Controllers/ColorFinder.swift b/Sources/hexcode/Controllers/ColorFinder.swift index f9c9f39..78187e4 100644 --- a/Sources/hexcode/Controllers/ColorFinder.swift +++ b/Sources/hexcode/Controllers/ColorFinder.swift @@ -36,7 +36,65 @@ final class ColorFinder: ColorFinding { } func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] { - [:] + + var duplicates: [String: [String]] = [:] + let lastColorSetIndex = colorSets.count - 1 + + for currentColorSetIndex in colorSets.indices { + let colorSet = colorSets[currentColorSetIndex] + let colors = colorSet.colorSet.colors + + if currentColorSetIndex == lastColorSetIndex { + continue + } + + for color in colors { + var colorNames: [String] = [] + let rgbHex = color.color.rgbHex + let nextIndex = currentColorSetIndex + 1 + + for otherColor in colorSets[nextIndex...] { + let appearanceNames = findAppearances( + for: rgbHex, + in: otherColor.colorSet.colors + ) + + if appearanceNames.isEmpty { + continue + } + + var name = otherColor.name + + if appearanceNames.count < otherColor.colorSet.colors.count { + name += " (\(joined(appearanceNames)))" + } + + colorNames.append(name) + } + + if !colorNames.isEmpty { + let currentColorSetAppearances = findAppearances( + for: rgbHex, + in: colors + ) + + var currentColorSetName = colorSet.name + + if currentColorSetAppearances.count < colors.count { + currentColorSetName += " (\(joined(currentColorSetAppearances)))" + } + + colorNames.append(currentColorSetName) + + if duplicates[rgbHex] == nil { + duplicates[rgbHex] = colorNames.sorted() + } + } + + } + } + + return duplicates } } diff --git a/Tests/hexcodeTests/ColorFinderTests.swift b/Tests/hexcodeTests/ColorFinderTests.swift index 05cb36b..55cf95c 100644 --- a/Tests/hexcodeTests/ColorFinderTests.swift +++ b/Tests/hexcodeTests/ColorFinderTests.swift @@ -128,4 +128,29 @@ final class ColorFinderTests: XCTestCase { // Then XCTAssertEqual(colors, ["cyanColorHex"]) } + + // MARK: - Test find duplicates + + func test_findDuplicates_inColorSetsWithMulticolorDuplicate_findsExpectedDuplicates() { + // When + let duplicates = sut.findDuplicates(in: [.defaultTextColorHex, .brandBlackColorHex]) + + // Then + XCTAssertEqual(duplicates, ["171717": ["brandBlackColorHex", "defaultTextHex (Any, Light)"]]) + } + + func test_findDuplicates_inColorSetsWithSingularDuplicates_findsExpectedDuplicates() { + // Given + let duplicatedWhite = NamedColorSet( + name: "duplicatedWhite", + colorSet: .Universal.Singular.white + ) + let colorSets = [.whiteColorHex, .blackColorHex, duplicatedWhite] + + // When + let colors = sut.findDuplicates(in: colorSets) + + // Then + XCTAssertEqual(colors, ["FFFFFF": ["duplicatedWhite", "whiteColorHex"]]) + } } diff --git a/Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift b/Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift index 30bb714..fd147cc 100644 --- a/Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift +++ b/Tests/hexcodeTests/Stubs/ColorSet+Stubs.swift @@ -7,6 +7,7 @@ extension ColorSet { enum Singular { static let white = makeColorSet(red: "0xFF", green: "0xFF", blue: "0xFF") static let black = makeColorSet(red: "0x00", green: "0x00", blue: "0x00") + static let brandBlack = makeColorSet(red: "0x17", green: "0x17", blue: "0x17") static let red = makeColorSet(red: "0xFF", green: "0x00", blue: "0x00") static let green = makeColorSet(red: "0x00", green: "0xFF", blue: "0x00") static let blue = makeColorSet(red: "0x00", green: "0x00", blue: "0xFF") diff --git a/Tests/hexcodeTests/Stubs/NamedColorSet+Stubs.swift b/Tests/hexcodeTests/Stubs/NamedColorSet+Stubs.swift index 17e79fd..6a67ed3 100644 --- a/Tests/hexcodeTests/Stubs/NamedColorSet+Stubs.swift +++ b/Tests/hexcodeTests/Stubs/NamedColorSet+Stubs.swift @@ -36,6 +36,11 @@ extension NamedColorSet { colorSet: .Universal.Multiple.defaultText ) + static let brandBlackColorHex: Self = NamedColorSet( + name: "brandBlackColorHex", + colorSet: .Universal.Singular.brandBlack + ) + static let cyanColorHex: Self = NamedColorSet( name: "cyanColorHex", colorSet: .Universal.Multiple.cyan From 679dd132832ff2ca9469cd1878ab1c3f82ba22d7 Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Sun, 29 Dec 2024 15:11:23 +0200 Subject: [PATCH 07/17] Remove deprecated URL init --- Sources/hexcode/Controllers/AssetCollector.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Sources/hexcode/Controllers/AssetCollector.swift b/Sources/hexcode/Controllers/AssetCollector.swift index 21d799b..ef59e74 100644 --- a/Sources/hexcode/Controllers/AssetCollector.swift +++ b/Sources/hexcode/Controllers/AssetCollector.swift @@ -106,19 +106,11 @@ extension AssetCollector { } private func getAssetName(from path: String) -> String { - makeURL(from: path) + URL(filePath: path) .deletingPathExtension() .lastPathComponent } - private func makeURL(from path: String) -> URL { - if #available(macOS 13.0, *) { - return URL(filePath: path) - } else { - return URL(fileURLWithPath: path) - } - } - private func readColorSet(at path: String) -> ColorSet? { let path = path + "/Contents.json" guard let fileData = fileManager.contents(atPath: path) else { return nil } From f4bc6c2adc47d61b4d737bae3e55036850fe8952 Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Sun, 29 Dec 2024 18:38:47 +0200 Subject: [PATCH 08/17] Include path in the color name --- .../hexcode/Controllers/AssetCollector.swift | 29 ++++++++++++++----- Tests/hexcodeTests/AssetCollectorTests.swift | 6 +++- Tests/hexcodeTests/hexcodeEndToEndTests.swift | 4 +-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/Sources/hexcode/Controllers/AssetCollector.swift b/Sources/hexcode/Controllers/AssetCollector.swift index ef59e74..cc3a2b7 100644 --- a/Sources/hexcode/Controllers/AssetCollector.swift +++ b/Sources/hexcode/Controllers/AssetCollector.swift @@ -25,7 +25,10 @@ final class AssetCollector: AssetCollecting { } let paths = try fileManager.contentsOfDirectory(atPath: directory) - let namedColorSets = await self.findColorSets(at: paths.map { "\(directory)/\($0)"}) + let namedColorSets = await self.findColorSets( + at: paths.map { "\(directory)/\($0)"}, + in: directory + ) return namedColorSets.sorted(by: { $0.name < $1.name }) } } @@ -51,6 +54,7 @@ extension AssetCollector { extension AssetCollector { private func findColorSets( at paths: [String], + in searchRootDirectory: String, alreadyFoundColorSets: [NamedColorSet] = [] ) async -> [NamedColorSet] { let colorSets = await withTaskGroup(of: [NamedColorSet].self) { group in @@ -60,13 +64,18 @@ extension AssetCollector { switch contentAtPath { case .colorSet(let colorSet): - return self.makeNamedColorset(from: colorSet, at: path) + return self.makeNamedColorset( + from: colorSet, + at: path, + in: searchRootDirectory + ) case .otherDirectory(let subpaths): guard !subpaths.isEmpty else { return [] } let fullSubpaths = subpaths.map { "\(path)/\($0)" } let colorSetsFromSubdirectory = await self.findColorSets( at: fullSubpaths, + in: searchRootDirectory, alreadyFoundColorSets: alreadyFoundColorSets ) return colorSetsFromSubdirectory @@ -83,8 +92,13 @@ extension AssetCollector { return colorSets } - private func makeNamedColorset(from colorSet: ColorSet, at path: String) -> [NamedColorSet] { - let assetName = getAssetName(from: path) + private func makeNamedColorset( + from colorSet: ColorSet, + at path: String, + in searchRootDirectory: String + ) -> [NamedColorSet] { + + let assetName = getAssetName(from: path, in: searchRootDirectory) let namedColorSet = NamedColorSet(name: assetName, colorSet: colorSet) return [namedColorSet] } @@ -105,10 +119,11 @@ extension AssetCollector { try fileManager.contentsOfDirectory(atPath: directory) } - private func getAssetName(from path: String) -> String { - URL(filePath: path) + private func getAssetName(from path: String, in searchRootDirectory: String) -> String { + let trimmedPath = String(path.trimmingPrefix(searchRootDirectory + "/")) + return URL(filePath: trimmedPath) .deletingPathExtension() - .lastPathComponent + .relativeString } private func readColorSet(at path: String) -> ColorSet? { diff --git a/Tests/hexcodeTests/AssetCollectorTests.swift b/Tests/hexcodeTests/AssetCollectorTests.swift index 5f47358..50f5b66 100644 --- a/Tests/hexcodeTests/AssetCollectorTests.swift +++ b/Tests/hexcodeTests/AssetCollectorTests.swift @@ -83,16 +83,20 @@ final class AssetCollectorTests: XCTestCase { let otherColorsDir = catalogPath + "/OtherColors" setMockDirectory(at: otherColorsDir, with: ["more_colors", "greenColorHex.colorset"]) setMockAsset(at: "\(otherColorsDir)/greenColorHex.colorset", with: ColorSetJSON.green) + var greenColorHex: NamedColorSet = .greenColorHex + greenColorHex.name = "OtherColors/greenColorHex" let moreColorsDir = otherColorsDir + "/more_colors" setMockDirectory(at: moreColorsDir, with: ["blueColorHex.colorset"]) setMockAsset(at: "\(moreColorsDir)/blueColorHex.colorset", with: ColorSetJSON.blue) + var blueColorHex: NamedColorSet = .blueColorHex + blueColorHex.name = "OtherColors/more_colors/blueColorHex" // When let assets = try await sut.collectAssets(in: catalogPath) // Then - XCTAssertEqual(assets, [.blueColorHex, .greenColorHex, .redColorHex]) + XCTAssertEqual(assets, [greenColorHex, blueColorHex, .redColorHex]) } } diff --git a/Tests/hexcodeTests/hexcodeEndToEndTests.swift b/Tests/hexcodeTests/hexcodeEndToEndTests.swift index 7e2216c..2b865b4 100644 --- a/Tests/hexcodeTests/hexcodeEndToEndTests.swift +++ b/Tests/hexcodeTests/hexcodeEndToEndTests.swift @@ -72,7 +72,7 @@ final class HexcodeEndToEndTests: XCTestCase { let (output, error) = try runHexcode(arguments: arguments) // Then - XCTAssertEqual(output, "blueColorHex\n") + XCTAssertEqual(output, "OtherColors/more_colors/blueColorHex\n") XCTAssertEqual(error, "") } @@ -175,9 +175,9 @@ final class HexcodeEndToEndTests: XCTestCase { XCTAssertEqual( output, """ - #E6E6FA lavender #E6E6FA OtherColors/lavenderHex #E6E6FA OtherColors/palePurpleHex + #E6E6FA lavender -- #F4EA2F darkSunflowerDuplicateHex #F4EA2F sunflowerDuplicateHex (Dark)\n From c403d98474a913d20e0fb60c433e7a0fd838dc1c Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Sun, 29 Dec 2024 20:18:11 +0200 Subject: [PATCH 09/17] Add separator between duplicates --- Sources/hexcode/HexcodeApp.swift | 15 ++++++++++++--- Tests/hexcodeTests/hexcodeEndToEndTests.swift | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Sources/hexcode/HexcodeApp.swift b/Sources/hexcode/HexcodeApp.swift index 30f6925..b2694fb 100644 --- a/Sources/hexcode/HexcodeApp.swift +++ b/Sources/hexcode/HexcodeApp.swift @@ -53,13 +53,22 @@ final class HexcodeApp { return } + var hasMoreThanOneDuplicate = false foundDuplicates .sorted { $0.key < $1.key } .forEach { duplicate in - duplicate.value.forEach { color in - output("#\(duplicate.key) \(color)") + if hasMoreThanOneDuplicate { + output("--") + } + + duplicate.value.forEach { color in + output("#\(duplicate.key) \(color)") + } + + if !hasMoreThanOneDuplicate { + hasMoreThanOneDuplicate = true + } } - } } } diff --git a/Tests/hexcodeTests/hexcodeEndToEndTests.swift b/Tests/hexcodeTests/hexcodeEndToEndTests.swift index 2b865b4..18e6d5c 100644 --- a/Tests/hexcodeTests/hexcodeEndToEndTests.swift +++ b/Tests/hexcodeTests/hexcodeEndToEndTests.swift @@ -186,6 +186,21 @@ final class HexcodeEndToEndTests: XCTestCase { XCTAssertEqual(error, "") } + func test_hexcode_findDuplicates_withSingleDuplicate_outputsDuplicateWithoutSeparator() throws { + let arguments = ["find-duplicates", "--directory=\(resourcePath)/AssetsWithDuplicates.xcassets/OtherColors"] + + let (output, error) = try runHexcode(arguments: arguments) + + XCTAssertEqual( + output, + """ + #E6E6FA lavenderHex + #E6E6FA palePurpleHex\n + """ + ) + XCTAssertEqual(error, "") + } + func test_hexcode_findDuplicates_inDicectoryWithoutDuplicates_outputsNoDuplicatesFoundMessage() throws { // Given let arguments = ["find-duplicates", "--directory=\(resourcePath)/AssetsInSubdirectories.xcassets"] From 11b0a9a2f067167e7b9ecddbf683a1dd9cfd94ab Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:19:10 +0200 Subject: [PATCH 10/17] Add tests for more cases of find-duplicates command logic --- Tests/hexcodeTests/ColorFinderTests.swift | 48 +++++++++++++- Tests/hexcodeTests/HexcodeAppTests.swift | 76 +++++++++++++++++++---- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/Tests/hexcodeTests/ColorFinderTests.swift b/Tests/hexcodeTests/ColorFinderTests.swift index 55cf95c..00b8a40 100644 --- a/Tests/hexcodeTests/ColorFinderTests.swift +++ b/Tests/hexcodeTests/ColorFinderTests.swift @@ -131,7 +131,7 @@ final class ColorFinderTests: XCTestCase { // MARK: - Test find duplicates - func test_findDuplicates_inColorSetsWithMulticolorDuplicate_findsExpectedDuplicates() { + func test_findDuplicates_inColorSetsWithMulticolorDuplicate_findsExpectedDuplicateValues() { // When let duplicates = sut.findDuplicates(in: [.defaultTextColorHex, .brandBlackColorHex]) @@ -139,13 +139,13 @@ final class ColorFinderTests: XCTestCase { XCTAssertEqual(duplicates, ["171717": ["brandBlackColorHex", "defaultTextHex (Any, Light)"]]) } - func test_findDuplicates_inColorSetsWithSingularDuplicates_findsExpectedDuplicates() { + func test_findDuplicates_inColorSetsWithSingularDuplicate_findsExpectedDuplicateValues() { // Given let duplicatedWhite = NamedColorSet( name: "duplicatedWhite", colorSet: .Universal.Singular.white ) - let colorSets = [.whiteColorHex, .blackColorHex, duplicatedWhite] + let colorSets: [NamedColorSet] = [.whiteColorHex, .blackColorHex, duplicatedWhite] // When let colors = sut.findDuplicates(in: colorSets) @@ -153,4 +153,46 @@ final class ColorFinderTests: XCTestCase { // Then XCTAssertEqual(colors, ["FFFFFF": ["duplicatedWhite", "whiteColorHex"]]) } + + func test_findDuplicates_inColorSetsWithMultipleDuplicates_returnsSortedExpectedDuplicateValues() { + // Given + var yellow: NamedColorSet = .sunflowerColorHex + yellow.name = "yellow" + + var snowWhite: NamedColorSet = .whiteColorHex + snowWhite.name = "snowWhite" + + let colorSets: [NamedColorSet] = [ + .blueColorHex, + .sunflowerColorHex, + .whiteColorHex, + .blackColorHex, + yellow, + .redColorHex, + snowWhite, + .greenColorHex, + .defaultTextColorHex, + ] + + // When + let colors = sut.findDuplicates(in: colorSets) + + // Then + XCTAssertEqual(colors, [ + "F4EA2F": ["sunflowerHex (Dark)", "yellow (Dark)"], + "FFFFFF": ["snowWhite", "whiteColorHex"], + "E8DE2A": ["sunflowerHex (Any)", "yellow (Any)"], + ]) + } + + func test_findDuplicates_inColorSetWithNoDuplicates_returnsEmptyDictionary() { + // Given + let colorSets: [NamedColorSet] = [.redColorHex, .greenColorHex, .blueColorHex] + + // When + let colors = sut.findDuplicates(in: colorSets) + + // Then + AssertEmpty(colors) + } } diff --git a/Tests/hexcodeTests/HexcodeAppTests.swift b/Tests/hexcodeTests/HexcodeAppTests.swift index 2bb3345..cc103dd 100644 --- a/Tests/hexcodeTests/HexcodeAppTests.swift +++ b/Tests/hexcodeTests/HexcodeAppTests.swift @@ -35,9 +35,9 @@ final class HexcodeAppTests: XCTestCase { XCTAssertEqual(mocks.assetCollector.calls, [.setFileManager(mocks.fileManager)]) } - // MARK: - Test run + // MARK: - Test runFindColor - func test_run_withoutDirectory_runsInCurrentDirectoryFromFileManager() async throws { + func test_runFindColor_withoutDirectory_runsInCurrentDirectoryFromFileManager() async throws { // Given let currentDirectory = "/currentDirectory" mocks.fileManager.results.currentDirectoryPath = currentDirectory @@ -50,7 +50,7 @@ final class HexcodeAppTests: XCTestCase { XCTAssertEqual(mocks.assetCollector.calls, [.collectAssetsIn(directory: currentDirectory)]) } - func test_run_inDirectory_runsInProvidedDirectory() async throws { + func test_runFindColor_inDirectory_runsInProvidedDirectory() async throws { // Given let searchDirectory = "/searchDirectory" @@ -61,7 +61,7 @@ final class HexcodeAppTests: XCTestCase { XCTAssertEqual(mocks.assetCollector.calls, [.collectAssetsIn(directory: searchDirectory)]) } - func test_run_whenAssetCollectorThrowsNotADirectoryError_rethrowsError() async throws { + func test_runFindColor_whenAssetCollectorThrowsNotADirectoryError_rethrowsError() async throws { // Given mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.notADirectory) @@ -71,7 +71,7 @@ final class HexcodeAppTests: XCTestCase { ) } - func test_run_whenAssetCollectorThrowsDirectoryNotFound_rethrowsError() async throws { + func test_runFindColor_whenAssetCollectorThrowsDirectoryNotFound_rethrowsError() async throws { // Given mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.directoryNotFound) @@ -81,7 +81,7 @@ final class HexcodeAppTests: XCTestCase { ) } - func test_run_whenAssetCollectorThrowsNotADirectoryError_doesNotLookForColors() async { + func test_runFindColor_whenAssetCollectorThrowsNotADirectoryError_doesNotLookForColors() async { // Given mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.notADirectory) @@ -93,7 +93,7 @@ final class HexcodeAppTests: XCTestCase { AssertEmpty(mocks.outputs) } - func test_run_whenAssetCollectorThrowsDirectoryNotFound_doesNotLookForColors() async { + func test_runFindColor_whenAssetCollectorThrowsDirectoryNotFound_doesNotLookForColors() async { // Given mocks.assetCollector.results.collectAssets = .failure(AssetCollector.Error.directoryNotFound) @@ -105,7 +105,7 @@ final class HexcodeAppTests: XCTestCase { AssertEmpty(mocks.outputs) } - func test_run_whenCollectedAssets_callsColorFinderWithCollectedAssets() async throws { + func test_runFindColor_whenCollectedAssets_callsColorFinderWithCollectedAssets() async throws { // Given let expectedColorSets: [NamedColorSet] = [ .blueColorHex, @@ -125,7 +125,7 @@ final class HexcodeAppTests: XCTestCase { ) } - func test_run_whenDidNotCollectAssets_callsColorFinderWithEmptyArray() async throws { + func test_runFindColor_whenDidNotCollectAssets_callsColorFinderWithEmptyArray() async throws { // Given let colorHex = "#F1F2F3" mocks.assetCollector.results.collectAssets = .success([]) @@ -140,7 +140,7 @@ final class HexcodeAppTests: XCTestCase { ) } - func test_run_whenSingleColorAssetIsFound_outputsAssetName() async throws { + func test_runFindColor_whenSingleColorAssetIsFound_outputsAssetName() async throws { // Given let expectedOutput = "white" mocks.colorFinder.results.find = [expectedOutput] @@ -152,7 +152,7 @@ final class HexcodeAppTests: XCTestCase { XCTAssertEqual(mocks.outputs, [expectedOutput]) } - func test_run_whenMultipleColorAssetIsFound_outputsAllAssetNames() async throws { + func test_runFindColor_whenMultipleColorAssetIsFound_outputsAllAssetNames() async throws { // Given let expectedOutputs = ["red", "green", "blue"] mocks.colorFinder.results.find = expectedOutputs @@ -164,7 +164,7 @@ final class HexcodeAppTests: XCTestCase { XCTAssertEqual(mocks.outputs, expectedOutputs) } - func test_run_whenNoColorAssetsFound_outputsNoColorsFoundMessage() async throws { + func test_runFindColor_whenNoColorAssetsFound_outputsNoColorsFoundMessage() async throws { // Given mocks.colorFinder.results.find = [] @@ -174,6 +174,58 @@ final class HexcodeAppTests: XCTestCase { // Then XCTAssertEqual(mocks.outputs, ["No \(blackHexStub) color found"]) } + + // MARK: Test runFindDuplicates + + func test_runFindDuplicates_whenSingleDuplicateFound_outputsDuplicateHexAndAssetNames() async throws { + // Given + mocks.colorFinder.results.findDuplicates = ["000000": ["snowWhite", "white"]] + + // When + try await sut.runFindDuplicates() + + // Then + XCTAssertEqual(mocks.outputs, [ + "#000000 snowWhite", + "#000000 white", + ]) + } + + func test_runFindDuplicates_whenNoDuplicateFound_outputsNoDuplicatesFoundMessage() async throws { + // Given + mocks.colorFinder.results.findDuplicates = [:] + + // When + try await sut.runFindDuplicates() + + // Then + XCTAssertEqual(mocks.outputs, ["No duplicates found"]) + } + + func test_runFindDuplicates_whenMultipleDuplicatesFound_outputsDuplicatesWithSeparator() async throws { + // Given + mocks.colorFinder.results.findDuplicates = [ + "FF00FF": ["magenta", "accentColor"], + "000000": ["snowWhite", "white"], + "FFFFFF": ["black", "darkestHex", "text (Any, Light)"] + ] + + // When + try await sut.runFindDuplicates() + + // Then + XCTAssertEqual(mocks.outputs, [ + "#000000 snowWhite", + "#000000 white", + "--", + "#FF00FF magenta", + "#FF00FF accentColor", + "--", + "#FFFFFF black", + "#FFFFFF darkestHex", + "#FFFFFF text (Any, Light)", + ]) + } } // MARK: - Private From 50de54d93e7a62107cb35d148f5a6db842e050c5 Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Tue, 31 Dec 2024 00:12:23 +0200 Subject: [PATCH 11/17] Improve the speed of performing find-duplicates command --- Sources/hexcode/Controllers/ColorFinder.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/hexcode/Controllers/ColorFinder.swift b/Sources/hexcode/Controllers/ColorFinder.swift index 78187e4..25f761a 100644 --- a/Sources/hexcode/Controllers/ColorFinder.swift +++ b/Sources/hexcode/Controllers/ColorFinder.swift @@ -40,18 +40,19 @@ final class ColorFinder: ColorFinding { var duplicates: [String: [String]] = [:] let lastColorSetIndex = colorSets.count - 1 - for currentColorSetIndex in colorSets.indices { + for currentColorSetIndex in 0...lastColorSetIndex { let colorSet = colorSets[currentColorSetIndex] let colors = colorSet.colorSet.colors - - if currentColorSetIndex == lastColorSetIndex { - continue - } + let nextIndex = currentColorSetIndex + 1 for color in colors { - var colorNames: [String] = [] let rgbHex = color.color.rgbHex - let nextIndex = currentColorSetIndex + 1 + + if duplicates.keys.contains(rgbHex) { + continue + } + + var colorNames: [String] = [] for otherColor in colorSets[nextIndex...] { let appearanceNames = findAppearances( @@ -77,6 +78,10 @@ final class ColorFinder: ColorFinding { for: rgbHex, in: colors ) + + if currentColorSetAppearances.isEmpty { + break + } var currentColorSetName = colorSet.name From 03c84de9a90e297e206dbd3c875c381586ff444a Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Tue, 31 Dec 2024 09:03:04 +0200 Subject: [PATCH 12/17] Reuse colorset name creation code --- Sources/hexcode/Controllers/ColorFinder.swift | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/Sources/hexcode/Controllers/ColorFinder.swift b/Sources/hexcode/Controllers/ColorFinder.swift index 25f761a..0b2ee60 100644 --- a/Sources/hexcode/Controllers/ColorFinder.swift +++ b/Sources/hexcode/Controllers/ColorFinder.swift @@ -27,11 +27,8 @@ final class ColorFinder: ColorFinding { guard !appearances.isEmpty else { return nil } - guard appearances.count < colors.count else { - return namedSet.name - } - return namedSet.name + " (\(joined(appearances)))" + return makeColorName(from: appearances, of: namedSet) } } @@ -41,54 +38,51 @@ final class ColorFinder: ColorFinding { let lastColorSetIndex = colorSets.count - 1 for currentColorSetIndex in 0...lastColorSetIndex { - let colorSet = colorSets[currentColorSetIndex] - let colors = colorSet.colorSet.colors - let nextIndex = currentColorSetIndex + 1 + let currentColorSet = colorSets[currentColorSetIndex] + let currentColors = currentColorSet.colorSet.colors + let nextColorSetIndex = currentColorSetIndex + 1 - for color in colors { - let rgbHex = color.color.rgbHex + for currentColor in currentColors { + let rgbHex = currentColor.color.rgbHex if duplicates.keys.contains(rgbHex) { continue } - + var colorNames: [String] = [] - for otherColor in colorSets[nextIndex...] { - let appearanceNames = findAppearances( + for otherColorSet in colorSets[nextColorSetIndex...] { + let otherColorSetAppearances = findAppearances( for: rgbHex, - in: otherColor.colorSet.colors + in: otherColorSet.colorSet.colors ) - if appearanceNames.isEmpty { + if otherColorSetAppearances.isEmpty { continue } - var name = otherColor.name - - if appearanceNames.count < otherColor.colorSet.colors.count { - name += " (\(joined(appearanceNames)))" - } - + let name = makeColorName( + from: otherColorSetAppearances, + of: otherColorSet + ) colorNames.append(name) } + // If at least one duplicate found, add the searched name too if !colorNames.isEmpty { let currentColorSetAppearances = findAppearances( for: rgbHex, - in: colors + in: currentColors ) if currentColorSetAppearances.isEmpty { break } - var currentColorSetName = colorSet.name - - if currentColorSetAppearances.count < colors.count { - currentColorSetName += " (\(joined(currentColorSetAppearances)))" - } - + let currentColorSetName = makeColorName( + from: currentColorSetAppearances, + of: currentColorSet + ) colorNames.append(currentColorSetName) if duplicates[rgbHex] == nil { @@ -132,4 +126,12 @@ extension ColorFinder { private func joined(_ appearances: [String]) -> String { appearances.joined(separator: ", ") } + + private func makeColorName(from appearances: [String], of namedColorSet: NamedColorSet) -> String { + if appearances.count < namedColorSet.colorSet.colors.count { + return "\(namedColorSet.name) (\(joined(appearances)))" + } else { + return namedColorSet.name + } + } } From 718a808b257489b751fa96ef9760834fabad50ec Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:18:58 +0200 Subject: [PATCH 13/17] Add safety check for invalid color componentswhen comparing duplicates --- .../hexcode/Controllers/AssetCollector.swift | 8 ++--- Sources/hexcode/Controllers/ColorFinder.swift | 6 +--- Tests/hexcodeTests/ColorFinderTests.swift | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/Sources/hexcode/Controllers/AssetCollector.swift b/Sources/hexcode/Controllers/AssetCollector.swift index cc3a2b7..3da5a24 100644 --- a/Sources/hexcode/Controllers/AssetCollector.swift +++ b/Sources/hexcode/Controllers/AssetCollector.swift @@ -105,8 +105,8 @@ extension AssetCollector { private func determineContentType(at path: String) -> PathContentType? { var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { return nil } - guard isDirectory.boolValue else { return .file } + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), + isDirectory.boolValue else { return .file } if !path.hasSuffix(".colorset"), let subpaths = try? contents(at: path) { return .otherDirectory(subpaths: subpaths) @@ -128,8 +128,8 @@ extension AssetCollector { private func readColorSet(at path: String) -> ColorSet? { let path = path + "/Contents.json" - guard let fileData = fileManager.contents(atPath: path) else { return nil } - guard let colorSet = try? JSONDecoder().decode(ColorSet.self, from: fileData) else { return nil } + guard let fileData = fileManager.contents(atPath: path), + let colorSet = try? JSONDecoder().decode(ColorSet.self, from: fileData) else { return nil } return colorSet } } diff --git a/Sources/hexcode/Controllers/ColorFinder.swift b/Sources/hexcode/Controllers/ColorFinder.swift index 0b2ee60..df6e256 100644 --- a/Sources/hexcode/Controllers/ColorFinder.swift +++ b/Sources/hexcode/Controllers/ColorFinder.swift @@ -45,7 +45,7 @@ final class ColorFinder: ColorFinding { for currentColor in currentColors { let rgbHex = currentColor.color.rgbHex - if duplicates.keys.contains(rgbHex) { + if rgbHex.isEmpty || duplicates.keys.contains(rgbHex) { continue } @@ -74,10 +74,6 @@ final class ColorFinder: ColorFinding { for: rgbHex, in: currentColors ) - - if currentColorSetAppearances.isEmpty { - break - } let currentColorSetName = makeColorName( from: currentColorSetAppearances, diff --git a/Tests/hexcodeTests/ColorFinderTests.swift b/Tests/hexcodeTests/ColorFinderTests.swift index 00b8a40..c5bd057 100644 --- a/Tests/hexcodeTests/ColorFinderTests.swift +++ b/Tests/hexcodeTests/ColorFinderTests.swift @@ -195,4 +195,36 @@ final class ColorFinderTests: XCTestCase { // Then AssertEmpty(colors) } + + func test_findDuplicates_inColorSetWithInvalidColors_returnsEmptyDictionary() { + // Given + let invalidColor = ColorSet( + colors: [ + .init( + color: .init( + colorSpace: .srgb, + components: .init( + alpha: "0", + red: "r", + green: "g", + blue: "b" + ) + ), + idiom: .iPhone + ) + ], + info: .init(author: "-", version: 1.0) + ) + let colorSets: [NamedColorSet] = [ + .init(name: "one", colorSet: invalidColor), + .init(name: "two", colorSet: invalidColor), + ] + + // When + let colors = sut.findDuplicates(in: colorSets) + + // Then + AssertEmpty(colors) + } + } From 8030b26205821183d585cc2988a28fb05c80050b Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Tue, 31 Dec 2024 15:13:58 +0200 Subject: [PATCH 14/17] Include find-duplicates command performance test in the repo --- Tests/hexcodeTests/ColorFinderTests.swift | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Tests/hexcodeTests/ColorFinderTests.swift b/Tests/hexcodeTests/ColorFinderTests.swift index c5bd057..c44a268 100644 --- a/Tests/hexcodeTests/ColorFinderTests.swift +++ b/Tests/hexcodeTests/ColorFinderTests.swift @@ -227,4 +227,72 @@ final class ColorFinderTests: XCTestCase { AssertEmpty(colors) } + // MARK: - Test performance + +#if !CI + func test_performance_findDuplicates_measureSpeed() { + // Given + let repeatingColorSets: [NamedColorSet] = Array(repeating: .blueColorHex, count: 10) + + Array(repeating: .redColorHex, count: 10) + + Array(repeating: .greenColorHex, count: 10) + + Array(repeating: .blackColorHex, count: 10) + + Array(repeating: .whiteColorHex, count: 10) + + Array(repeating: .cyanColorHex, count: 10) + + Array(repeating: .brandBlackColorHex, count: 10) + + Array(repeating: .defaultTextColorHex, count: 10) + + Array(repeating: .sunflowerColorHex, count: 10) + + Array(repeating: .cyanColorHex, count: 10) + + let allDifferentColorSets: [NamedColorSet] = (0..<255).map { + NamedColorSet( + name: "\($0) colorset", + colorSet: ColorSet( + colors: [ + ColorAsset( + color: .init( + colorSpace: .srgb, + components: .init( + alpha: "0xFF", + red: String(format: "0x%02X", $0), + green: "0x00", + blue: "0xFF" + ) + ), + idiom: .iPhone + ) + ], + info: .init(author: "author", version: 1.0) + ) + ) + } + + var repeatingColorSetResults: [String: [String]] = [:] + var allDifferentColorSetResults: [String: [String]] = [:] + + // Then + self.measure { + // When + repeatingColorSetResults = sut.findDuplicates(in: repeatingColorSets) + allDifferentColorSetResults = sut.findDuplicates(in: allDifferentColorSets) + } + + // Then + XCTAssertEqual( + repeatingColorSetResults, + [ + "000000": Array(repeating: "blackColorHex", count: 10), + "FFFFFF": Array(repeating: "whiteColorHex", count: 10), + "FF0000": Array(repeating: "redColorHex", count: 10), + "00FF00": Array(repeating: "greenColorHex", count: 10), + "E7E7E7": Array(repeating: "defaultTextHex (Dark)", count: 10), + "F4EA2F": Array(repeating: "sunflowerHex (Dark)", count: 10), + "00FFFF": Array(repeating: "cyanColorHex", count: 20), + "0000FF": Array(repeating: "blueColorHex", count: 10), + "171717": Array(repeating: "brandBlackColorHex", count: 10) + Array(repeating: "defaultTextHex (Any, Light)", count: 10), + "E8DE2A": Array(repeating: "sunflowerHex (Any)", count: 10) + ] + ) + AssertEmpty(allDifferentColorSetResults) + } +#endif } From d1e142b7e18e377824adae80f0eefbb645802d43 Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Tue, 31 Dec 2024 16:24:28 +0200 Subject: [PATCH 15/17] Prevent out-of-bounds exception when there are no color assets --- Sources/hexcode/Controllers/ColorFinder.swift | 4 ++-- Tests/hexcodeTests/hexcodeEndToEndTests.swift | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/hexcode/Controllers/ColorFinder.swift b/Sources/hexcode/Controllers/ColorFinder.swift index df6e256..b2c3c16 100644 --- a/Sources/hexcode/Controllers/ColorFinder.swift +++ b/Sources/hexcode/Controllers/ColorFinder.swift @@ -35,9 +35,9 @@ final class ColorFinder: ColorFinding { func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] { var duplicates: [String: [String]] = [:] - let lastColorSetIndex = colorSets.count - 1 + let colorSetCount = colorSets.count - for currentColorSetIndex in 0...lastColorSetIndex { + for currentColorSetIndex in 0.. Date: Tue, 31 Dec 2024 16:27:21 +0200 Subject: [PATCH 16/17] Update README.md with find-duplicates command description --- README.md | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 76d460b..a2204b8 100644 --- a/README.md +++ b/README.md @@ -13,20 +13,38 @@ A tool that finds Xcode color assets by their hex codes. The idea behind this to ### ⚠️Disclaimer: For now, the tool only supports searching for exact values of color components as ints, floats or hexadecimals, without conversion between settings like content type (sRGB, Display P3, Gray Gamma 2.2 etc.), and ignoring some other settings like Gamut etc. ## Usage -The tool can be used like this from terminal: +By default, the tool can be used from the terminal to search for matches to a given color code: ``` hexcode #ffa500 ``` ...where `#ffa500` is a hex color code, with or without `#`, case-insensitive. -This way `hexcode` will recursively search for the color assets matching the hex rgb value, starting from current directory. The output will be one or more matching color set names, or a message notifying that it haven't found an asset with the given color. The command also has some very simple error handling and might exit with error. +When used this way, `hexcode` will recursively search for the color assets matching the hex rgb value, starting from the current directory. The output will be one or more matching color set names, or a message in case it haven't found an asset with the given color. Color names include path to their color asset, relative to the project. The command also has some very simple error handling and might exit with error. More arguments and options will be added in the future with new features, they can be found using the `--help` flag. -#### Examples +#### Default usage examples Color found: -hexcode_usage_color_found +hexcode_usage_color_found_1 Color not found: -hexcode_usage_no_such_color +hexcode_usage_no_such_color + +### Find Duplicates +Hexcode can also check a project or a directory for duplicated color assets. +```zsh +hexcode find-duplicates +``` +Output example when there are duplicates: +``` +#24658F MyProject/Assets.xcassets/AccentColor +#24658F MyProject/Colors.xcassets/defaultAccent +-- +#999999 MyProject/Assets.xcassets/appColor/gray +#999999 MyProject/Colors.xcassets/neutralGray +``` +Output when duplicates not found: +``` +No duplicates found +``` ## Installation 1. Clone the repository to your machine From ec47c7ec9d0257d0553095eb39eb61101c2b0981 Mon Sep 17 00:00:00 2001 From: Artem Yelizarov <52959979+artem-y@users.noreply.github.com> Date: Tue, 31 Dec 2024 16:29:45 +0200 Subject: [PATCH 17/17] Bump version to 0.2.0 in configuration --- Sources/hexcode/Commands/Hexcode.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/hexcode/Commands/Hexcode.swift b/Sources/hexcode/Commands/Hexcode.swift index a7572e7..b1e8f9d 100644 --- a/Sources/hexcode/Commands/Hexcode.swift +++ b/Sources/hexcode/Commands/Hexcode.swift @@ -10,7 +10,7 @@ struct Hexcode: AsyncParsableCommand { by their hexadecimal codes. """, usage: "hexcode [--directory ]", - version: "hexcode feature/find-duplicates", + version: "hexcode 0.2.0", subcommands: [ FindColor.self, FindDuplicates.self,