From e8204797accabbb4c7eb80aa58347fc129630269 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Tue, 26 Aug 2025 14:04:49 -0400 Subject: [PATCH 1/4] Support multiple toolchain selectors with `uninstall` Allow specifying multiple toolchain selectors at once when using install. `$ swiftly uninstall 6.1.1 6.1.2` Issue: #412 --- Sources/Swiftly/Uninstall.swift | 299 +++++++++++++++++++----- Tests/SwiftlyTests/UninstallTests.swift | 186 +++++++++++++++ 2 files changed, 427 insertions(+), 58 deletions(-) diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index 2054d314..5f1b16fa 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 UninstallCancelledException: 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,253 @@ 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 = 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 + ) + } - guard !toolchains.isEmpty else { - await ctx.message("No toolchains can be uninstalled that match \"\(self.toolchain)\"") - return + private func processAllSelector(_ config: Config) -> [ToolchainVersion] { + return 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 hasErrors(selectionResult) { + try await handleSelectionErrors(ctx, selectionResult) } - if !self.root.assumeYes { - await ctx.message("The following toolchains will be uninstalled:") + let toolchains = prepareToolchainsForUninstall(selectionResult) - for toolchain in toolchains { - await ctx.message(" \(toolchain)") + guard !toolchains.isEmpty else { + 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 UninstallCancelledException() + } + + if !root.assumeYes { + try await confirmUninstallation(ctx, toolchains, selectionResult.selectorToToolchains) + } + + return toolchains + } + + private func hasErrors(_ result: ToolchainSelectionResult) -> Bool { + return !result.invalidSelectors.isEmpty || !result.noMatchSelectors.isEmpty + } - guard await ctx.promptForConfirmation(defaultBehavior: true) else { + private func handleSelectionErrors(_ ctx: SwiftlyCoreContext, _ result: ToolchainSelectionResult) async throws { + var errorMessages: [String] = [] + + 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 UninstallCancelledException() } + } else { + // No valid toolchains found at all + await ctx.message("No valid toolchains found to uninstall.") + throw UninstallCancelledException() + } + } + + 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], + _ selectorMapping: [String: [ToolchainVersion]] + ) async throws { + await displayToolchainConfirmation(ctx, toolchains) + + guard await ctx.promptForConfirmation(defaultBehavior: true) else { + await ctx.message("Aborting uninstall") + throw UninstallCancelledException() + } + } + + 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 = applySortingStrategy(toolchains, config: startingConfig) + + for (index, toolchain) in sortedToolchains.enumerated() { + await 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 - } + try await handleInUseToolchainReplacement(ctx, toolchain, sortedToolchains, &config) + } - 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.execute(ctx, toolchain, &config, verbose: root.verbose) + } + + await displayCompletionMessage(ctx, sortedToolchains.count) + } + + private func applySortingStrategy(_ toolchains: [ToolchainVersion], config: Config) -> [ToolchainVersion] { + return 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 = createReplacementSelector(for: toolchain) + + if let replacement = findSuitableReplacement(config, replacementSelector, excluding: allUninstallTargets) { + let pathChanged = try await Use.execute(ctx, replacement, globalDefault: true, verbose: 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 await Self.execute(ctx, toolchain, &config, verbose: self.root.verbose) + // 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 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..b53e222e 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"] + ) + + // 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") }) + } } From b187687749d72c2ea620dab08fdf9b52ac0ef337 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Tue, 26 Aug 2025 15:05:34 -0400 Subject: [PATCH 2/4] Fixup docs/lints --- .../SwiftlyDocs.docc/swiftly-cli-reference.md | 10 +++-- Sources/Swiftly/Uninstall.swift | 45 ++++++++++--------- 2 files changed, 30 insertions(+), 25 deletions(-) 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 5f1b16fa..698edada 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -85,7 +85,7 @@ struct Uninstall: SwiftlyCommand { for toolchainSelector in self.toolchains { if toolchainSelector == UninstallConstants.allSelector { - let allInstalledToolchains = processAllSelector(config) + let allInstalledToolchains = self.processAllSelector(config) allToolchains.formUnion(allInstalledToolchains) selectorToToolchains[toolchainSelector] = allInstalledToolchains } else { @@ -113,7 +113,7 @@ struct Uninstall: SwiftlyCommand { } private func processAllSelector(_ config: Config) -> [ToolchainVersion] { - return config.listInstalledToolchains(selector: nil).sorted { a, b in + config.listInstalledToolchains(selector: nil).sorted { a, b in a != config.inUse && (b == config.inUse || a < b) } } @@ -134,11 +134,11 @@ struct Uninstall: SwiftlyCommand { _ ctx: SwiftlyCoreContext, _ selectionResult: ToolchainSelectionResult ) async throws -> [ToolchainVersion] { - if hasErrors(selectionResult) { - try await handleSelectionErrors(ctx, selectionResult) + if self.hasErrors(selectionResult) { + try await self.handleSelectionErrors(ctx, selectionResult) } - let toolchains = prepareToolchainsForUninstall(selectionResult) + let toolchains = self.prepareToolchainsForUninstall(selectionResult) guard !toolchains.isEmpty else { if self.toolchains.count == 1 { @@ -149,15 +149,15 @@ struct Uninstall: SwiftlyCommand { throw UninstallCancelledException() } - if !root.assumeYes { - try await confirmUninstallation(ctx, toolchains, selectionResult.selectorToToolchains) + if !self.root.assumeYes { + try await self.confirmUninstallation(ctx, toolchains, selectionResult.selectorToToolchains) } return toolchains } private func hasErrors(_ result: ToolchainSelectionResult) -> Bool { - return !result.invalidSelectors.isEmpty || !result.noMatchSelectors.isEmpty + !result.invalidSelectors.isEmpty || !result.noMatchSelectors.isEmpty } private func handleSelectionErrors(_ ctx: SwiftlyCoreContext, _ result: ToolchainSelectionResult) async throws { @@ -202,9 +202,9 @@ struct Uninstall: SwiftlyCommand { private func confirmUninstallation( _ ctx: SwiftlyCoreContext, _ toolchains: [ToolchainVersion], - _ selectorMapping: [String: [ToolchainVersion]] + _ _: [String: [ToolchainVersion]] ) async throws { - await displayToolchainConfirmation(ctx, toolchains) + await self.displayToolchainConfirmation(ctx, toolchains) guard await ctx.promptForConfirmation(defaultBehavior: true) else { await ctx.message("Aborting uninstall") @@ -227,25 +227,25 @@ struct Uninstall: SwiftlyCommand { await ctx.message() // Apply proper sorting with access to config - let sortedToolchains = applySortingStrategy(toolchains, config: startingConfig) + let sortedToolchains = self.applySortingStrategy(toolchains, config: startingConfig) for (index, toolchain) in sortedToolchains.enumerated() { - await displayProgress(ctx, index: index, total: sortedToolchains.count, toolchain: toolchain) + await self.displayProgress(ctx, index: index, total: sortedToolchains.count, toolchain: toolchain) var config = try await Config.load(ctx) if toolchain == config.inUse { - try await handleInUseToolchainReplacement(ctx, toolchain, sortedToolchains, &config) + try await self.handleInUseToolchainReplacement(ctx, toolchain, sortedToolchains, &config) } - try await Self.execute(ctx, toolchain, &config, verbose: root.verbose) + try await Self.execute(ctx, toolchain, &config, verbose: self.root.verbose) } - await displayCompletionMessage(ctx, sortedToolchains.count) + await self.displayCompletionMessage(ctx, sortedToolchains.count) } private func applySortingStrategy(_ toolchains: [ToolchainVersion], config: Config) -> [ToolchainVersion] { - return toolchains.sorted { a, b in + toolchains.sorted { a, b in a != config.inUse && (b == config.inUse || a < b) } } @@ -256,10 +256,10 @@ struct Uninstall: SwiftlyCommand { _ allUninstallTargets: [ToolchainVersion], _ config: inout Config ) async throws { - let replacementSelector = createReplacementSelector(for: toolchain) + let replacementSelector = self.createReplacementSelector(for: toolchain) - if let replacement = findSuitableReplacement(config, replacementSelector, excluding: allUninstallTargets) { - let pathChanged = try await Use.execute(ctx, replacement, globalDefault: true, verbose: root.verbose, &config) + 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) } @@ -291,7 +291,8 @@ struct Uninstall: SwiftlyCommand { // Try the specific selector first if let replacement = config.listInstalledToolchains(selector: selector) .filter({ !excluding.contains($0) }) - .max() { + .max() + { return replacement } @@ -302,7 +303,7 @@ struct Uninstall: SwiftlyCommand { } // Finally, try any remaining toolchain - return config.installedToolchains.filter({ !excluding.contains($0) }).max() + return config.installedToolchains.filter { !excluding.contains($0) }.max() } private func displayProgress(_ ctx: SwiftlyCoreContext, index: Int, total: Int, toolchain: ToolchainVersion) async { @@ -313,7 +314,7 @@ struct Uninstall: SwiftlyCommand { private func displayCompletionMessage(_ ctx: SwiftlyCoreContext, _ toolchainCount: Int) async { await ctx.message() - if toolchains.count == 1 { + 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)") From 5ed0ce6d695377fba2d328556db7932a5402f782 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Tue, 26 Aug 2025 15:37:56 -0400 Subject: [PATCH 3/4] Fixup test --- Tests/SwiftlyTests/UninstallTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SwiftlyTests/UninstallTests.swift b/Tests/SwiftlyTests/UninstallTests.swift index b53e222e..050cb99a 100644 --- a/Tests/SwiftlyTests/UninstallTests.swift +++ b/Tests/SwiftlyTests/UninstallTests.swift @@ -436,7 +436,7 @@ import Testing let output = try await SwiftlyTests.runWithMockedIO( Uninstall.self, ["uninstall", ToolchainVersion.oldStable.name, "xcode"], // xcode gets filtered out - input: ["y"] + input: ["y", "y"] // First y for error prompt, second y for confirmation ) // Should only uninstall the valid, non-filtered toolchain From fb86a7a8e317bb5b2e89c3416f7ddd23f98caf25 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Fri, 29 Aug 2025 16:25:32 -0400 Subject: [PATCH 4/4] Fixup error name --- Sources/Swiftly/Uninstall.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index 698edada..cff43464 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -10,7 +10,7 @@ struct Uninstall: SwiftlyCommand { static let allSelector = "all" } - private struct UninstallCancelledException: Error {} + private struct UninstallCancelledError: Error {} private struct ToolchainSelectionResult { let validToolchains: Set @@ -146,7 +146,7 @@ struct Uninstall: SwiftlyCommand { } else { await ctx.message("No toolchains can be uninstalled that match the provided selectors") } - throw UninstallCancelledException() + throw UninstallCancelledError() } if !self.root.assumeYes { @@ -180,12 +180,12 @@ struct Uninstall: SwiftlyCommand { 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") - throw UninstallCancelledException() + throw UninstallCancelledError() } } else { // No valid toolchains found at all await ctx.message("No valid toolchains found to uninstall.") - throw UninstallCancelledException() + throw UninstallCancelledError() } } @@ -208,7 +208,7 @@ struct Uninstall: SwiftlyCommand { guard await ctx.promptForConfirmation(defaultBehavior: true) else { await ctx.message("Aborting uninstall") - throw UninstallCancelledException() + throw UninstallCancelledError() } }