diff --git a/Sources/XcodesKit/Models+FirstWithVersion.swift b/Sources/XcodesKit/Models+FirstWithVersion.swift index e18bb39..303bfd1 100644 --- a/Sources/XcodesKit/Models+FirstWithVersion.swift +++ b/Sources/XcodesKit/Models+FirstWithVersion.swift @@ -41,7 +41,28 @@ public extension Array where Element == InstalledXcode { /// If there are multiple matches, or no matches, nil is returned. func first(withVersion version: Version) -> InstalledXcode? { findXcode(version: version, in: self, versionKeyPath: \.version) - } + } + + /// Returns the newest InstalledXcode that shares the requested major and minor versions when: + /// - No prerelease or build metadata identifiers were specified. + /// - The requested patch version is 0 (i.e. not explicitly provided). + /// - An exact match via `first(withVersion:)` could not be found. + /// Prefers release builds when available, otherwise falls back to prerelease builds. + func latestMatchingMajorMinorVersion(withVersion version: Version) -> InstalledXcode? { + let matchingVersions = filter { + $0.version.major == version.major && + $0.version.minor == version.minor + } + + if matchingVersions.isEmpty { + return nil + } + + let releaseVersions = matchingVersions.filter { $0.version.prereleaseIdentifiers.isEmpty } + let candidates = releaseVersions.isEmpty ? matchingVersions : releaseVersions + + return candidates.max(by: { $0.version < $1.version }) + } } extension Version { diff --git a/Sources/XcodesKit/XcodeSelect.swift b/Sources/XcodesKit/XcodeSelect.swift index 7a4c193..40c0f17 100644 --- a/Sources/XcodesKit/XcodeSelect.swift +++ b/Sources/XcodesKit/XcodeSelect.swift @@ -24,16 +24,27 @@ public func selectXcode(shouldPrint: Bool, pathOrVersion: String, directory: Pat let versionToSelect = pathOrVersion.isEmpty ? Version.fromXcodeVersionFile() : Version(xcodeVersion: pathOrVersion) let installedXcodes = Current.files.installedXcodes(directory) - if let version = versionToSelect, - let installedXcode = installedXcodes.first(withVersion: version) { + if let version = versionToSelect { let selectedInstalledXcodeVersion = installedXcodes.first { output.out.hasPrefix($0.path.string) }.map { $0.version } - if installedXcode.version == selectedInstalledXcodeVersion { + if version == selectedInstalledXcodeVersion { Current.logging.log("Xcode \(version) is already selected".green) Current.shell.exit(0) return Promise.value(()) } - return selectXcodeAtPath(installedXcode.path.string) + var xcodeToSelect = installedXcodes.first(withVersion: version) + if xcodeToSelect == nil, let fallbackInstalledXcode = installedXcodes.latestMatchingMajorMinorVersion(withVersion: version) { + Current.logging.log("Xcode \(version) is not installed. Selecting \(fallbackInstalledXcode.version) instead.".green) + xcodeToSelect = fallbackInstalledXcode + } + + guard let selectedXcode = xcodeToSelect else { + Current.logging.log("Xcode \(version) is not installed".red) + Current.shell.exit(1) + return Promise.value(()) + } + + return selectXcodeAtPath(selectedXcode.path.string) .done { output in Current.logging.log("Selected \(output.out)".green) Current.shell.exit(0) diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 1282d92..be9d1f2 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -1169,6 +1169,38 @@ final class XcodesKitTests: XCTestCase { """) } + + func test_SelectFallsBackToLatestPatchVersion() { + var log = "" + XcodesKit.Current.logging.log = { log.append($0 + "\n") } + + let fallbackXcode = InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!, version: Version(2, 0, 1)) + Current.files.installedXcodes = { _ in [fallbackXcode] } + + var xcodeSelectPrintPathCallCount = 0 + Current.shell.xcodeSelectPrintPath = { + defer { xcodeSelectPrintPathCallCount += 1 } + if xcodeSelectPrintPathCallCount == 0 { + return Promise.value((status: 0, out: "/Applications/Xcode-1.0.0.app/Contents/Developer", err: "")) + } else { + return Promise.value((status: 0, out: "\(fallbackXcode.path.string)/Contents/Developer", err: "")) + } + } + + Current.shell.xcodeSelectSwitch = { _, path in + XCTAssertEqual(path, fallbackXcode.path.string) + return Promise.value((status: 0, out: "", err: "")) + } + + selectXcode(shouldPrint: false, pathOrVersion: "2.0", directory: Path.root.join("Applications")) + .cauterize() + + XCTAssertEqual(log, """ + Xcode 2.0 is not installed. Selecting 2.0.1 instead. + Selected /Applications/Xcode-2.0.1.app/Contents/Developer + + """) + } func test_Installed_InteractiveTerminal() { var log = ""