Deduplicate source paths case-insensitively

This commit is contained in:
John Burwell 2026-06-02 16:50:01 -05:00
parent ffb6e497ec
commit 9ec6b905bc
4 changed files with 79 additions and 23 deletions

View File

@ -76,11 +76,19 @@ nonisolated struct SourceCandidate: Identifiable, Hashable, Sendable {
var id: String { var id: String {
[ [
providerID, providerID,
sourceRootURL.standardizedFileURL.absoluteString sourceIdentityKey(for: sourceRootURL)
].joined(separator: "::") ].joined(separator: "::")
} }
} }
nonisolated func sourceIdentityKey(for url: URL) -> String {
if url.isFileURL {
return url.standardizedFileURL.resolvingSymlinksInPath().path.lowercased()
}
return url.standardized.absoluteString.lowercased()
}
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable { nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
case pending case pending
case running case running

View File

@ -965,7 +965,7 @@ enum JavaContentScanner {
under root: URL, under root: URL,
providerID: PlatformProviderID providerID: PlatformProviderID
) -> [SourceCandidate] { ) -> [SourceCandidate] {
let uniqueCandidates = Dictionary(grouping: candidates, by: \.sourceRootURL).compactMap { _, groupedCandidates in let uniqueCandidates = Dictionary(grouping: candidates, by: { sourceIdentityKey(for: $0.sourceRootURL) }).compactMap { _, groupedCandidates in
groupedCandidates.max { lhs, rhs in groupedCandidates.max { lhs, rhs in
lhs.confidence < rhs.confidence lhs.confidence < rhs.confidence
} }
@ -1066,7 +1066,7 @@ enum JavaContentScanner {
while !queue.isEmpty && folders.count < maxFolderCount { while !queue.isEmpty && folders.count < maxFolderCount {
let current = queue.removeFirst() let current = queue.removeFirst()
let normalizedURL = current.url.standardizedFileURL let normalizedURL = current.url.standardizedFileURL
guard seen.insert(normalizedURL.path).inserted else { guard seen.insert(sourceIdentityKey(for: normalizedURL)).inserted else {
continue continue
} }
@ -1121,7 +1121,7 @@ enum JavaContentScanner {
for url in urls { for url in urls {
let standardizedURL = url.standardizedFileURL let standardizedURL = url.standardizedFileURL
guard seen.insert(standardizedURL.path).inserted else { guard seen.insert(sourceIdentityKey(for: standardizedURL)).inserted else {
continue continue
} }

View File

@ -183,9 +183,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
let providerID = probe?.providerID ?? LocalFolderSourceAccess().accessorIdentifier let providerID = probe?.providerID ?? LocalFolderSourceAccess().accessorIdentifier
let edition = probe?.edition ?? .bedrock let edition = probe?.edition ?? .bedrock
if sources.contains(where: { $0.id == normalizedURL }) { if let existingSourceID = existingSourceID(matching: normalizedURL) {
sourceCandidates.removeAll { $0.sourceRootURL == normalizedURL } sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: normalizedURL) }
updateSource(normalizedURL) { source in updateSource(existingSourceID) { source in
if source.bookmarkData == nil { if source.bookmarkData == nil {
source.bookmarkData = bookmarkData source.bookmarkData = bookmarkData
} }
@ -204,8 +204,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
} }
} }
startScan(for: normalizedURL, mode: .fullScan) startScan(for: existingSourceID, mode: .fullScan)
return normalizedURL return existingSourceID
} }
var source = MinecraftSource( var source = MinecraftSource(
@ -224,7 +224,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.scanDiagnostic = warning source.scanDiagnostic = warning
} }
let sourceID = addSource(source, shouldPersist: true, shouldScan: true) let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
sourceCandidates.removeAll { $0.sourceRootURL == sourceID } sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: sourceID) }
return sourceID return sourceID
} }
@ -232,8 +232,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
let normalizedURL = candidate.sourceRootURL.standardizedFileURL let normalizedURL = candidate.sourceRootURL.standardizedFileURL
let bookmarkData = securityScopedBookmarkData(for: normalizedURL) let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
if sources.contains(where: { $0.id == normalizedURL }) { if let existingSourceID = existingSourceID(matching: normalizedURL) {
updateSource(normalizedURL) { source in updateSource(existingSourceID) { source in
if source.bookmarkData == nil { if source.bookmarkData == nil {
source.bookmarkData = bookmarkData source.bookmarkData = bookmarkData
} }
@ -247,9 +247,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.displayName = candidate.displayName source.displayName = candidate.displayName
source.capabilities = source.origin.defaultCapabilities source.capabilities = source.origin.defaultCapabilities
} }
sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == normalizedURL } removeSourceCandidates(matching: candidate, sourceID: existingSourceID)
startScan(for: normalizedURL, mode: .fullScan) startScan(for: existingSourceID, mode: .fullScan)
return normalizedURL return existingSourceID
} }
var source = MinecraftSource( var source = MinecraftSource(
@ -266,14 +266,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.displayName = candidate.displayName source.displayName = candidate.displayName
let sourceID = addSource(source, shouldPersist: true, shouldScan: true) let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == sourceID } removeSourceCandidates(matching: candidate, sourceID: sourceID)
return sourceID return sourceID
} }
@discardableResult @discardableResult
func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL { func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL {
if sources.contains(where: { $0.id == source.id }) { if let existingSourceID = existingSourceID(matching: source.id) {
updateSource(source.id) { existingSource in updateSource(existingSourceID) { existingSource in
existingSource.origin = source.origin existingSource.origin = source.origin
existingSource.accessDescriptor = source.accessDescriptor existingSource.accessDescriptor = source.accessDescriptor
existingSource.providerID = source.accessDescriptor.accessorIdentifier existingSource.providerID = source.accessDescriptor.accessorIdentifier
@ -300,13 +300,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
if shouldPersist { if shouldPersist {
persistSourceIfAvailable(withID: source.id) persistSourceIfAvailable(withID: existingSourceID(matching: source.id) ?? source.id)
} }
if shouldScan { if shouldScan {
startScan(for: source.id, mode: .fullScan) startScan(for: existingSourceID(matching: source.id) ?? source.id, mode: .fullScan)
} }
return source.id return existingSourceID(matching: source.id) ?? source.id
} }
func source(withID sourceID: URL) -> MinecraftSource? { func source(withID sourceID: URL) -> MinecraftSource? {
@ -704,8 +704,26 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
private func candidateAlreadyAdded(_ candidate: SourceCandidate) -> Bool { private func candidateAlreadyAdded(_ candidate: SourceCandidate) -> Bool {
sources.contains { source in sources.contains { source in
source.id == candidate.sourceRootURL.standardizedFileURL sourceIdentityKey(for: source.id) == sourceIdentityKey(for: candidate.sourceRootURL)
|| source.folderURL == candidate.sourceRootURL.standardizedFileURL || sourceIdentityKey(for: source.folderURL) == sourceIdentityKey(for: candidate.sourceRootURL)
}
}
private func existingSourceID(matching url: URL) -> URL? {
let identity = sourceIdentityKey(for: url)
return sources.first { source in
sourceIdentityKey(for: source.id) == identity
|| sourceIdentityKey(for: source.folderURL) == identity
}?.id
}
private func removeSourceCandidates(matching candidate: SourceCandidate, sourceID: URL) {
let candidateIdentity = sourceIdentityKey(for: candidate.sourceRootURL)
let sourceIdentity = sourceIdentityKey(for: sourceID)
sourceCandidates.removeAll {
$0.id == candidate.id
|| sourceIdentityKey(for: $0.sourceRootURL) == candidateIdentity
|| sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentity
} }
} }

View File

@ -385,6 +385,36 @@ struct World_Manager_for_MinecraftTests {
#expect(candidates.first?.detectedKinds.contains(.mod) == true) #expect(candidates.first?.detectedKinds.contains(.mod) == true)
} }
@Test func javaProviderDeduplicatesCaseVariantSourceRoots() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let upperRootURL = workingURL.appendingPathComponent("CurseForge/Minecraft", isDirectory: true)
let lowerRootURL = workingURL.appendingPathComponent("curseforge/minecraft", isDirectory: true)
let firstInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleOne", isDirectory: true)
let secondInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleTwo", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
for instanceURL in [firstInstanceURL, secondInstanceURL] {
try fileManager.createDirectory(
at: instanceURL.appendingPathComponent("mods", isDirectory: true),
withIntermediateDirectories: true
)
try Data("jar".utf8).write(to: instanceURL.appendingPathComponent("mods/ExampleMod.jar"))
}
guard fileManager.fileExists(atPath: lowerRootURL.path) else {
return
}
let candidates = JavaContentScanner.discoverSourceCandidates(
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
searchRoots: [upperRootURL, lowerRootURL]
)
#expect(candidates.count == 1)
#expect(sourceIdentityKey(for: candidates[0].sourceRootURL) == sourceIdentityKey(for: upperRootURL))
}
@Test func javaAggregateRootDiscoversNestedInstanceItems() async throws { @Test func javaAggregateRootDiscoversNestedInstanceItems() async throws {
let fileManager = FileManager.default let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)