diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 554b2721..81478abd 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -252,15 +252,15 @@ macOS ONLY: There is a special selector for swiftly to use your Xcode toolchain. Remove an installed toolchain. ``` -swiftly uninstall [--assume-yes] [--verbose] [--version] [--help] +swiftly uninstall ... [--assume-yes] [--verbose] [--version] [--help] ``` -**toolchain:** +**toolchains:** *The toolchain(s) to uninstall.* -The toolchain selector provided determines which toolchains to uninstall. Specific toolchains can be uninstalled by using their full names as the selector, for example a full stable release version with patch (a.b.c): +The list of toolchain selectors determines which toolchains to uninstall. Specific toolchains can be uninstalled by using their full names as the selector, for example a full stable release version with patch (a.b.c): $ swiftly uninstall 5.2.1 @@ -268,6 +268,10 @@ Or a full snapshot name with date (a.b-snapshot-YYYY-mm-dd): $ swiftly uninstall 5.7-snapshot-2022-06-20 +Multiple toolchain selectors can uninstall multiple toolchains at once: + + $ swiftly uninstall 5.2.1 6.0.1 + Less specific selectors can be used to uninstall multiple toolchains at once. For instance, the patch version can be omitted to uninstall all toolchains associated with a given minor version release: $ swiftly uninstall 5.6 diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index 2054d314..cff43464 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -6,11 +6,24 @@ struct Uninstall: SwiftlyCommand { abstract: "Remove an installed toolchain." ) + private enum UninstallConstants { + static let allSelector = "all" + } + + private struct UninstallCancelledError: Error {} + + private struct ToolchainSelectionResult { + let validToolchains: Set + let selectorToToolchains: [String: [ToolchainVersion]] + let invalidSelectors: [String] + let noMatchSelectors: [String] + } + @Argument(help: ArgumentHelp( "The toolchain(s) to uninstall.", discussion: """ - The toolchain selector provided determines which toolchains to uninstall. Specific \ + The list of toolchain selectors determines which toolchains to uninstall. Specific \ toolchains can be uninstalled by using their full names as the selector, for example \ a full stable release version with patch (a.b.c): @@ -20,6 +33,10 @@ struct Uninstall: SwiftlyCommand { $ swiftly uninstall 5.7-snapshot-2022-06-20 + Multiple toolchain selectors can uninstall multiple toolchains at once: + + $ swiftly uninstall 5.2.1 6.0.1 + Less specific selectors can be used to uninstall multiple toolchains at once. For instance, \ the patch version can be omitted to uninstall all toolchains associated with a given minor version release: @@ -39,7 +56,7 @@ struct Uninstall: SwiftlyCommand { $ swiftly uninstall all """ )) - var toolchain: String + var toolchains: [String] @OptionGroup var root: GlobalOptions @@ -54,87 +71,254 @@ struct Uninstall: SwiftlyCommand { } let startingConfig = try await Config.load(ctx) + let selectionResult = try await parseAndValidateToolchainSelectors(startingConfig) + let confirmedToolchains = try await handleErrorsAndGetConfirmation(ctx, selectionResult) - var toolchains: [ToolchainVersion] - if self.toolchain == "all" { - // Sort the uninstalled toolchains such that the in-use toolchain will be uninstalled last. - // This avoids printing any unnecessary output from using new toolchains while the uninstall is in progress. - toolchains = startingConfig.listInstalledToolchains(selector: nil).sorted { a, b in - a != startingConfig.inUse && (b == startingConfig.inUse || a < b) - } - } else { - let selector = try ToolchainSelector(parsing: self.toolchain) - var installedToolchains = startingConfig.listInstalledToolchains(selector: selector) - // This is in the unusual case that the inUse toolchain is not listed in the installed toolchains - if let inUse = startingConfig.inUse, selector.matches(toolchain: inUse) && !startingConfig.installedToolchains.contains(inUse) { - installedToolchains.append(inUse) + try await executeUninstalls(ctx, confirmedToolchains, startingConfig) + } + + private func parseAndValidateToolchainSelectors(_ config: Config) async throws -> ToolchainSelectionResult { + var allToolchains: Set = Set() + var selectorToToolchains: [String: [ToolchainVersion]] = [:] + var invalidSelectors: [String] = [] + var noMatchSelectors: [String] = [] + + for toolchainSelector in self.toolchains { + if toolchainSelector == UninstallConstants.allSelector { + let allInstalledToolchains = self.processAllSelector(config) + allToolchains.formUnion(allInstalledToolchains) + selectorToToolchains[toolchainSelector] = allInstalledToolchains + } else { + do { + let installedToolchains = try processIndividualSelector(toolchainSelector, config) + + if installedToolchains.isEmpty { + noMatchSelectors.append(toolchainSelector) + } else { + allToolchains.formUnion(installedToolchains) + selectorToToolchains[toolchainSelector] = installedToolchains + } + } catch { + invalidSelectors.append(toolchainSelector) + } } - toolchains = installedToolchains } - // Filter out the xcode toolchain here since it is not uninstallable - toolchains.removeAll(where: { $0 == .xcodeVersion }) + return ToolchainSelectionResult( + validToolchains: allToolchains, + selectorToToolchains: selectorToToolchains, + invalidSelectors: invalidSelectors, + noMatchSelectors: noMatchSelectors + ) + } + + private func processAllSelector(_ config: Config) -> [ToolchainVersion] { + config.listInstalledToolchains(selector: nil).sorted { a, b in + a != config.inUse && (b == config.inUse || a < b) + } + } + + private func processIndividualSelector(_ selector: String, _ config: Config) throws -> [ToolchainVersion] { + let toolchainSelector = try ToolchainSelector(parsing: selector) + var installedToolchains = config.listInstalledToolchains(selector: toolchainSelector) + + // This handles the unusual case that the inUse toolchain is not listed in the installed toolchains + if let inUse = config.inUse, toolchainSelector.matches(toolchain: inUse) && !config.installedToolchains.contains(inUse) { + installedToolchains.append(inUse) + } + + return installedToolchains + } + + private func handleErrorsAndGetConfirmation( + _ ctx: SwiftlyCoreContext, + _ selectionResult: ToolchainSelectionResult + ) async throws -> [ToolchainVersion] { + if self.hasErrors(selectionResult) { + try await self.handleSelectionErrors(ctx, selectionResult) + } + + let toolchains = self.prepareToolchainsForUninstall(selectionResult) guard !toolchains.isEmpty else { - await ctx.message("No toolchains can be uninstalled that match \"\(self.toolchain)\"") - return + if self.toolchains.count == 1 { + await ctx.message("No toolchains can be uninstalled that match \"\(self.toolchains[0])\"") + } else { + await ctx.message("No toolchains can be uninstalled that match the provided selectors") + } + throw UninstallCancelledError() } if !self.root.assumeYes { - await ctx.message("The following toolchains will be uninstalled:") + try await self.confirmUninstallation(ctx, toolchains, selectionResult.selectorToToolchains) + } - for toolchain in toolchains { - await ctx.message(" \(toolchain)") - } + return toolchains + } + + private func hasErrors(_ result: ToolchainSelectionResult) -> Bool { + !result.invalidSelectors.isEmpty || !result.noMatchSelectors.isEmpty + } + + private func handleSelectionErrors(_ ctx: SwiftlyCoreContext, _ result: ToolchainSelectionResult) async throws { + var errorMessages: [String] = [] - guard await ctx.promptForConfirmation(defaultBehavior: true) else { + if !result.invalidSelectors.isEmpty { + errorMessages.append("Invalid toolchain selectors: \(result.invalidSelectors.joined(separator: ", "))") + } + + if !result.noMatchSelectors.isEmpty { + errorMessages.append("No toolchains match these selectors: \(result.noMatchSelectors.joined(separator: ", "))") + } + + for message in errorMessages { + await ctx.message(message) + } + + // If we have some valid selections, ask user if they want to proceed + if !result.validToolchains.isEmpty { + await ctx.message("\nFound \(result.validToolchains.count) toolchain(s) from valid selectors. Continue with uninstalling these?") + guard await ctx.promptForConfirmation(defaultBehavior: false) else { await ctx.message("Aborting uninstall") - return + throw UninstallCancelledError() } + } else { + // No valid toolchains found at all + await ctx.message("No valid toolchains found to uninstall.") + throw UninstallCancelledError() + } + } + + private func prepareToolchainsForUninstall(_ selectionResult: ToolchainSelectionResult) -> [ToolchainVersion] { + // Convert Set back to Array - sorting will be done in execution phase with proper config access + var toolchains = Array(selectionResult.validToolchains) + + // Filter out the xcode toolchain here since it is not uninstallable + toolchains.removeAll(where: { $0 == .xcodeVersion }) + + return toolchains + } + + private func confirmUninstallation( + _ ctx: SwiftlyCoreContext, + _ toolchains: [ToolchainVersion], + _ _: [String: [ToolchainVersion]] + ) async throws { + await self.displayToolchainConfirmation(ctx, toolchains) + + guard await ctx.promptForConfirmation(defaultBehavior: true) else { + await ctx.message("Aborting uninstall") + throw UninstallCancelledError() + } + } + + private func displayToolchainConfirmation(_ ctx: SwiftlyCoreContext, _ toolchains: [ToolchainVersion]) async { + await ctx.message("The following toolchains will be uninstalled:") + for toolchain in toolchains.sorted() { + await ctx.message(" \(toolchain)") } + } + private func executeUninstalls( + _ ctx: SwiftlyCoreContext, + _ toolchains: [ToolchainVersion], + _ startingConfig: Config + ) async throws { await ctx.message() - for toolchain in toolchains { + // Apply proper sorting with access to config + let sortedToolchains = self.applySortingStrategy(toolchains, config: startingConfig) + + for (index, toolchain) in sortedToolchains.enumerated() { + await self.displayProgress(ctx, index: index, total: sortedToolchains.count, toolchain: toolchain) + var config = try await Config.load(ctx) - // If the in-use toolchain was one of the uninstalled toolchains, use a new toolchain. if toolchain == config.inUse { - let selector: ToolchainSelector - switch toolchain { - case let .stable(sr): - // If a.b.c was previously in use, switch to the latest a.b toolchain. - selector = .stable(major: sr.major, minor: sr.minor, patch: nil) - case let .snapshot(s): - // If a snapshot was previously in use, switch to the latest snapshot associated with that branch. - selector = .snapshot(branch: s.branch, date: nil) - case .xcode: - // Xcode will not be in the list of installed toolchains, so this is only here for completeness - selector = .xcode - } - - if let toUse = config.listInstalledToolchains(selector: selector) - .filter({ !toolchains.contains($0) }) - .max() - ?? config.listInstalledToolchains(selector: .latest).filter({ !toolchains.contains($0) }).max() - ?? config.installedToolchains.filter({ !toolchains.contains($0) }).max() - { - let pathChanged = try await Use.execute(ctx, toUse, globalDefault: true, verbose: self.root.verbose, &config) - if pathChanged { - try await Self.handlePathChange(ctx) - } - } else { - // If there are no more toolchains installed, just unuse the currently active toolchain. - config.inUse = nil - try config.save(ctx) - } + try await self.handleInUseToolchainReplacement(ctx, toolchain, sortedToolchains, &config) } try await Self.execute(ctx, toolchain, &config, verbose: self.root.verbose) } + await self.displayCompletionMessage(ctx, sortedToolchains.count) + } + + private func applySortingStrategy(_ toolchains: [ToolchainVersion], config: Config) -> [ToolchainVersion] { + toolchains.sorted { a, b in + a != config.inUse && (b == config.inUse || a < b) + } + } + + private func handleInUseToolchainReplacement( + _ ctx: SwiftlyCoreContext, + _ toolchain: ToolchainVersion, + _ allUninstallTargets: [ToolchainVersion], + _ config: inout Config + ) async throws { + let replacementSelector = self.createReplacementSelector(for: toolchain) + + if let replacement = self.findSuitableReplacement(config, replacementSelector, excluding: allUninstallTargets) { + let pathChanged = try await Use.execute(ctx, replacement, globalDefault: true, verbose: self.root.verbose, &config) + if pathChanged { + try await Self.handlePathChange(ctx) + } + } else { + config.inUse = nil + try config.save(ctx) + } + } + + private func createReplacementSelector(for toolchain: ToolchainVersion) -> ToolchainSelector { + switch toolchain { + case let .stable(sr): + // If a.b.c was previously in use, switch to the latest a.b toolchain. + return .stable(major: sr.major, minor: sr.minor, patch: nil) + case let .snapshot(s): + // If a snapshot was previously in use, switch to the latest snapshot associated with that branch. + return .snapshot(branch: s.branch, date: nil) + case .xcode: + // Xcode will not be in the list of installed toolchains, so this is only here for completeness + return .xcode + } + } + + private func findSuitableReplacement( + _ config: Config, + _ selector: ToolchainSelector, + excluding: [ToolchainVersion] + ) -> ToolchainVersion? { + // Try the specific selector first + if let replacement = config.listInstalledToolchains(selector: selector) + .filter({ !excluding.contains($0) }) + .max() + { + return replacement + } + + // Try latest stable as fallback, but only if there are stable toolchains + let stableToolchains = config.installedToolchains.filter { $0.isStableRelease() && !excluding.contains($0) } + if !stableToolchains.isEmpty { + return stableToolchains.max() + } + + // Finally, try any remaining toolchain + return config.installedToolchains.filter { !excluding.contains($0) }.max() + } + + private func displayProgress(_ ctx: SwiftlyCoreContext, index: Int, total: Int, toolchain: ToolchainVersion) async { + if total > 1 { + await ctx.message("[\(index + 1)/\(total)] Processing \(toolchain)") + } + } + + private func displayCompletionMessage(_ ctx: SwiftlyCoreContext, _ toolchainCount: Int) async { await ctx.message() - await ctx.message("\(toolchains.count) toolchain(s) successfully uninstalled") + if self.toolchains.count == 1 { + await ctx.message("\(toolchainCount) toolchain(s) successfully uninstalled") + } else { + await ctx.message("Successfully uninstalled \(toolchainCount) toolchain(s) from \(self.toolchains.count) selector(s)") + } } static func execute( diff --git a/Tests/SwiftlyTests/UninstallTests.swift b/Tests/SwiftlyTests/UninstallTests.swift index 711cd7b3..050cb99a 100644 --- a/Tests/SwiftlyTests/UninstallTests.swift +++ b/Tests/SwiftlyTests/UninstallTests.swift @@ -285,4 +285,190 @@ import Testing let output = try await SwiftlyTests.runWithMockedIO(Uninstall.self, ["uninstall", "-y", ToolchainVersion.xcodeVersion.name]) #expect(!output.filter { $0.contains("No toolchains can be uninstalled that match \"xcode\"") }.isEmpty) } + + // MARK: - Multiple Selector Tests + + /// Tests that multiple valid selectors work correctly + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .oldMainSnapshot, .newMainSnapshot])) + func uninstallMultipleValidSelectors() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", ToolchainVersion.oldStable.name, ToolchainVersion.newMainSnapshot.name], + input: ["y"] + ) + + // Verify both toolchains were uninstalled + try await SwiftlyTests.validateInstalledToolchains( + [.newStable, .oldMainSnapshot], + description: "multiple valid selectors should uninstall both toolchains" + ) + + // Verify output shows confirmation message but no total summary + #expect(output.contains { $0.contains("The following toolchains will be uninstalled:") }) + #expect(output.contains { $0.contains("Successfully uninstalled") && $0.contains("from 2 selector(s)") }) + } + + /// Tests deduplication when selectors overlap + @Test(.mockedSwiftlyVersion()) + func uninstallOverlappingSelectors() async throws { + // Set up test with stable releases that can overlap + try await SwiftlyTests.withMockedHome(homeName: Self.homeName, toolchains: [.oldStable, .oldStableNewPatch]) { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", "5.6", ToolchainVersion.oldStable.name], // 5.6 selector matches both 5.6.0 and 5.6.3 + input: ["y"] + ) + + // Should uninstall both toolchains (5.6 matches both) + try await SwiftlyTests.validateInstalledToolchains( + [], + description: "overlapping selectors should deduplicate correctly" + ) + + // Verify toolchains are shown in flat list format + #expect(output.contains { $0.contains("The following toolchains will be uninstalled:") }) + } + } + + /// Tests multiple selectors with progress indication + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .oldMainSnapshot])) + func uninstallMultipleSelectorsProgress() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", "-y", ToolchainVersion.oldStable.name, ToolchainVersion.newStable.name, ToolchainVersion.oldMainSnapshot.name] + ) + + // Verify progress indicators appear + #expect(output.contains { $0.contains("[1/3] Processing") }) + #expect(output.contains { $0.contains("[2/3] Processing") }) + #expect(output.contains { $0.contains("[3/3] Processing") }) + + try await SwiftlyTests.validateInstalledToolchains( + [], + description: "multiple selectors with progress should uninstall all" + ) + } + + // MARK: - Error Handling Tests + + /// Tests mixed valid and invalid selectors with user choice to proceed + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable])) + func uninstallMixedValidInvalidSelectors() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", ToolchainVersion.oldStable.name, "invalid-selector", ToolchainVersion.newStable.name], + input: ["y", "y"] // First y for error prompt, second y for confirmation + ) + + // Should show error about invalid selector + #expect(output.contains { $0.contains("Invalid toolchain selectors: invalid-selector") }) + + // Should ask user if they want to proceed with valid ones + #expect(output.contains { $0.contains("Found 2 toolchain(s) from valid selectors. Continue") }) + + // Should uninstall the valid ones + try await SwiftlyTests.validateInstalledToolchains( + [], + description: "should proceed with valid selectors after user confirmation" + ) + } + + /// Tests mixed valid and invalid selectors with user choice to abort + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable])) + func uninstallMixedValidInvalidSelectorsAbort() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", ToolchainVersion.oldStable.name, "invalid-selector"], + input: ["n"] // Abort at error prompt + ) + + // Should show error and abort + #expect(output.contains { $0.contains("Invalid toolchain selectors: invalid-selector") }) + #expect(output.contains { $0.contains("Aborting uninstall") }) + + // Should not uninstall anything + try await SwiftlyTests.validateInstalledToolchains( + [.oldStable, .newStable], + description: "should not uninstall anything when user aborts" + ) + } + + /// Tests selectors with no matches + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable])) + func uninstallNoMatchSelectors() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", "main-snapshot", "5.99.0"] // Neither installed + ) + + #expect(output.contains { $0.contains("No toolchains match these selectors: main-snapshot, 5.99.0") }) + #expect(output.contains { $0.contains("No valid toolchains found to uninstall") }) + + // Nothing should be uninstalled + try await SwiftlyTests.validateInstalledToolchains( + [.oldStable], + description: "no-match selectors should not uninstall anything" + ) + } + + /// Tests all invalid selectors + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable])) + func uninstallAllInvalidSelectors() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", "invalid-1", "invalid-2"] + ) + + #expect(output.contains { $0.contains("Invalid toolchain selectors: invalid-1, invalid-2") }) + #expect(output.contains { $0.contains("No valid toolchains found to uninstall") }) + + try await SwiftlyTests.validateInstalledToolchains( + [.oldStable], + description: "all invalid selectors should not uninstall anything" + ) + } + + // MARK: - Edge Cases + + /// Tests multiple selectors where some result in empty matches after filtering + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable])) + func uninstallMultipleSelectorsFiltered() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", ToolchainVersion.oldStable.name, "xcode"], // xcode gets filtered out + input: ["y", "y"] // First y for error prompt, second y for confirmation + ) + + // Should only uninstall the valid, non-filtered toolchain + try await SwiftlyTests.validateInstalledToolchains( + [], + description: "should handle filtering correctly" + ) + + // Should show multiple selector completion message since we provided 2 selectors + #expect(output.contains { $0.contains("The following toolchains will be uninstalled:") }) + #expect(output.contains { $0.contains("Successfully uninstalled 1 toolchain(s) from 2 selector(s)") }) + } + + /// Tests multiple selectors with in-use toolchain replacement + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .oldMainSnapshot], inUse: .oldStable)) + func uninstallMultipleSelectorsInUse() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Uninstall.self, + ["uninstall", "-y", ToolchainVersion.oldStable.name, ToolchainVersion.oldMainSnapshot.name] + ) + + // Should uninstall both + try await SwiftlyTests.validateInstalledToolchains( + [.newStable], + description: "should uninstall multiple including in-use" + ) + + // Should switch to newStable since oldStable was in-use and got uninstalled + try await SwiftlyTests.validateInUse(expected: .newStable) + + // Should show progress for multiple toolchains + #expect(output.contains { $0.contains("[1/2] Processing") }) + #expect(output.contains { $0.contains("[2/2] Processing") }) + } }