Skip to content

Commit e9a6daa

Browse files
authored
Support multiple toolchain selectors with uninstall (#413)
* 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 * Fixup docs/lints * Fixup test * Fixup error name
1 parent 1a26c0e commit e9a6daa

File tree

3 files changed

+434
-60
lines changed

3 files changed

+434
-60
lines changed

Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,22 +252,26 @@ macOS ONLY: There is a special selector for swiftly to use your Xcode toolchain.
252252
Remove an installed toolchain.
253253

254254
```
255-
swiftly uninstall <toolchain> [--assume-yes] [--verbose] [--version] [--help]
255+
swiftly uninstall <toolchains>... [--assume-yes] [--verbose] [--version] [--help]
256256
```
257257

258-
**toolchain:**
258+
**toolchains:**
259259

260260
*The toolchain(s) to uninstall.*
261261

262262

263-
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):
263+
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):
264264

265265
$ swiftly uninstall 5.2.1
266266

267267
Or a full snapshot name with date (a.b-snapshot-YYYY-mm-dd):
268268

269269
$ swiftly uninstall 5.7-snapshot-2022-06-20
270270

271+
Multiple toolchain selectors can uninstall multiple toolchains at once:
272+
273+
$ swiftly uninstall 5.2.1 6.0.1
274+
271275
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:
272276

273277
$ swiftly uninstall 5.6

Sources/Swiftly/Uninstall.swift

Lines changed: 241 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,24 @@ struct Uninstall: SwiftlyCommand {
66
abstract: "Remove an installed toolchain."
77
)
88

9+
private enum UninstallConstants {
10+
static let allSelector = "all"
11+
}
12+
13+
private struct UninstallCancelledError: Error {}
14+
15+
private struct ToolchainSelectionResult {
16+
let validToolchains: Set<ToolchainVersion>
17+
let selectorToToolchains: [String: [ToolchainVersion]]
18+
let invalidSelectors: [String]
19+
let noMatchSelectors: [String]
20+
}
21+
922
@Argument(help: ArgumentHelp(
1023
"The toolchain(s) to uninstall.",
1124
discussion: """
1225
13-
The toolchain selector provided determines which toolchains to uninstall. Specific \
26+
The list of toolchain selectors determines which toolchains to uninstall. Specific \
1427
toolchains can be uninstalled by using their full names as the selector, for example \
1528
a full stable release version with patch (a.b.c):
1629
@@ -20,6 +33,10 @@ struct Uninstall: SwiftlyCommand {
2033
2134
$ swiftly uninstall 5.7-snapshot-2022-06-20
2235
36+
Multiple toolchain selectors can uninstall multiple toolchains at once:
37+
38+
$ swiftly uninstall 5.2.1 6.0.1
39+
2340
Less specific selectors can be used to uninstall multiple toolchains at once. For instance, \
2441
the patch version can be omitted to uninstall all toolchains associated with a given minor version release:
2542
@@ -39,7 +56,7 @@ struct Uninstall: SwiftlyCommand {
3956
$ swiftly uninstall all
4057
"""
4158
))
42-
var toolchain: String
59+
var toolchains: [String]
4360

4461
@OptionGroup var root: GlobalOptions
4562

@@ -54,87 +71,254 @@ struct Uninstall: SwiftlyCommand {
5471
}
5572

5673
let startingConfig = try await Config.load(ctx)
74+
let selectionResult = try await parseAndValidateToolchainSelectors(startingConfig)
75+
let confirmedToolchains = try await handleErrorsAndGetConfirmation(ctx, selectionResult)
5776

58-
var toolchains: [ToolchainVersion]
59-
if self.toolchain == "all" {
60-
// Sort the uninstalled toolchains such that the in-use toolchain will be uninstalled last.
61-
// This avoids printing any unnecessary output from using new toolchains while the uninstall is in progress.
62-
toolchains = startingConfig.listInstalledToolchains(selector: nil).sorted { a, b in
63-
a != startingConfig.inUse && (b == startingConfig.inUse || a < b)
64-
}
65-
} else {
66-
let selector = try ToolchainSelector(parsing: self.toolchain)
67-
var installedToolchains = startingConfig.listInstalledToolchains(selector: selector)
68-
// This is in the unusual case that the inUse toolchain is not listed in the installed toolchains
69-
if let inUse = startingConfig.inUse, selector.matches(toolchain: inUse) && !startingConfig.installedToolchains.contains(inUse) {
70-
installedToolchains.append(inUse)
77+
try await executeUninstalls(ctx, confirmedToolchains, startingConfig)
78+
}
79+
80+
private func parseAndValidateToolchainSelectors(_ config: Config) async throws -> ToolchainSelectionResult {
81+
var allToolchains: Set<ToolchainVersion> = Set()
82+
var selectorToToolchains: [String: [ToolchainVersion]] = [:]
83+
var invalidSelectors: [String] = []
84+
var noMatchSelectors: [String] = []
85+
86+
for toolchainSelector in self.toolchains {
87+
if toolchainSelector == UninstallConstants.allSelector {
88+
let allInstalledToolchains = self.processAllSelector(config)
89+
allToolchains.formUnion(allInstalledToolchains)
90+
selectorToToolchains[toolchainSelector] = allInstalledToolchains
91+
} else {
92+
do {
93+
let installedToolchains = try processIndividualSelector(toolchainSelector, config)
94+
95+
if installedToolchains.isEmpty {
96+
noMatchSelectors.append(toolchainSelector)
97+
} else {
98+
allToolchains.formUnion(installedToolchains)
99+
selectorToToolchains[toolchainSelector] = installedToolchains
100+
}
101+
} catch {
102+
invalidSelectors.append(toolchainSelector)
103+
}
71104
}
72-
toolchains = installedToolchains
73105
}
74106

75-
// Filter out the xcode toolchain here since it is not uninstallable
76-
toolchains.removeAll(where: { $0 == .xcodeVersion })
107+
return ToolchainSelectionResult(
108+
validToolchains: allToolchains,
109+
selectorToToolchains: selectorToToolchains,
110+
invalidSelectors: invalidSelectors,
111+
noMatchSelectors: noMatchSelectors
112+
)
113+
}
114+
115+
private func processAllSelector(_ config: Config) -> [ToolchainVersion] {
116+
config.listInstalledToolchains(selector: nil).sorted { a, b in
117+
a != config.inUse && (b == config.inUse || a < b)
118+
}
119+
}
120+
121+
private func processIndividualSelector(_ selector: String, _ config: Config) throws -> [ToolchainVersion] {
122+
let toolchainSelector = try ToolchainSelector(parsing: selector)
123+
var installedToolchains = config.listInstalledToolchains(selector: toolchainSelector)
124+
125+
// This handles the unusual case that the inUse toolchain is not listed in the installed toolchains
126+
if let inUse = config.inUse, toolchainSelector.matches(toolchain: inUse) && !config.installedToolchains.contains(inUse) {
127+
installedToolchains.append(inUse)
128+
}
129+
130+
return installedToolchains
131+
}
132+
133+
private func handleErrorsAndGetConfirmation(
134+
_ ctx: SwiftlyCoreContext,
135+
_ selectionResult: ToolchainSelectionResult
136+
) async throws -> [ToolchainVersion] {
137+
if self.hasErrors(selectionResult) {
138+
try await self.handleSelectionErrors(ctx, selectionResult)
139+
}
140+
141+
let toolchains = self.prepareToolchainsForUninstall(selectionResult)
77142

78143
guard !toolchains.isEmpty else {
79-
await ctx.message("No toolchains can be uninstalled that match \"\(self.toolchain)\"")
80-
return
144+
if self.toolchains.count == 1 {
145+
await ctx.message("No toolchains can be uninstalled that match \"\(self.toolchains[0])\"")
146+
} else {
147+
await ctx.message("No toolchains can be uninstalled that match the provided selectors")
148+
}
149+
throw UninstallCancelledError()
81150
}
82151

83152
if !self.root.assumeYes {
84-
await ctx.message("The following toolchains will be uninstalled:")
153+
try await self.confirmUninstallation(ctx, toolchains, selectionResult.selectorToToolchains)
154+
}
85155

86-
for toolchain in toolchains {
87-
await ctx.message(" \(toolchain)")
88-
}
156+
return toolchains
157+
}
158+
159+
private func hasErrors(_ result: ToolchainSelectionResult) -> Bool {
160+
!result.invalidSelectors.isEmpty || !result.noMatchSelectors.isEmpty
161+
}
162+
163+
private func handleSelectionErrors(_ ctx: SwiftlyCoreContext, _ result: ToolchainSelectionResult) async throws {
164+
var errorMessages: [String] = []
89165

90-
guard await ctx.promptForConfirmation(defaultBehavior: true) else {
166+
if !result.invalidSelectors.isEmpty {
167+
errorMessages.append("Invalid toolchain selectors: \(result.invalidSelectors.joined(separator: ", "))")
168+
}
169+
170+
if !result.noMatchSelectors.isEmpty {
171+
errorMessages.append("No toolchains match these selectors: \(result.noMatchSelectors.joined(separator: ", "))")
172+
}
173+
174+
for message in errorMessages {
175+
await ctx.message(message)
176+
}
177+
178+
// If we have some valid selections, ask user if they want to proceed
179+
if !result.validToolchains.isEmpty {
180+
await ctx.message("\nFound \(result.validToolchains.count) toolchain(s) from valid selectors. Continue with uninstalling these?")
181+
guard await ctx.promptForConfirmation(defaultBehavior: false) else {
91182
await ctx.message("Aborting uninstall")
92-
return
183+
throw UninstallCancelledError()
93184
}
185+
} else {
186+
// No valid toolchains found at all
187+
await ctx.message("No valid toolchains found to uninstall.")
188+
throw UninstallCancelledError()
189+
}
190+
}
191+
192+
private func prepareToolchainsForUninstall(_ selectionResult: ToolchainSelectionResult) -> [ToolchainVersion] {
193+
// Convert Set back to Array - sorting will be done in execution phase with proper config access
194+
var toolchains = Array(selectionResult.validToolchains)
195+
196+
// Filter out the xcode toolchain here since it is not uninstallable
197+
toolchains.removeAll(where: { $0 == .xcodeVersion })
198+
199+
return toolchains
200+
}
201+
202+
private func confirmUninstallation(
203+
_ ctx: SwiftlyCoreContext,
204+
_ toolchains: [ToolchainVersion],
205+
_ _: [String: [ToolchainVersion]]
206+
) async throws {
207+
await self.displayToolchainConfirmation(ctx, toolchains)
208+
209+
guard await ctx.promptForConfirmation(defaultBehavior: true) else {
210+
await ctx.message("Aborting uninstall")
211+
throw UninstallCancelledError()
212+
}
213+
}
214+
215+
private func displayToolchainConfirmation(_ ctx: SwiftlyCoreContext, _ toolchains: [ToolchainVersion]) async {
216+
await ctx.message("The following toolchains will be uninstalled:")
217+
for toolchain in toolchains.sorted() {
218+
await ctx.message(" \(toolchain)")
94219
}
220+
}
95221

222+
private func executeUninstalls(
223+
_ ctx: SwiftlyCoreContext,
224+
_ toolchains: [ToolchainVersion],
225+
_ startingConfig: Config
226+
) async throws {
96227
await ctx.message()
97228

98-
for toolchain in toolchains {
229+
// Apply proper sorting with access to config
230+
let sortedToolchains = self.applySortingStrategy(toolchains, config: startingConfig)
231+
232+
for (index, toolchain) in sortedToolchains.enumerated() {
233+
await self.displayProgress(ctx, index: index, total: sortedToolchains.count, toolchain: toolchain)
234+
99235
var config = try await Config.load(ctx)
100236

101-
// If the in-use toolchain was one of the uninstalled toolchains, use a new toolchain.
102237
if toolchain == config.inUse {
103-
let selector: ToolchainSelector
104-
switch toolchain {
105-
case let .stable(sr):
106-
// If a.b.c was previously in use, switch to the latest a.b toolchain.
107-
selector = .stable(major: sr.major, minor: sr.minor, patch: nil)
108-
case let .snapshot(s):
109-
// If a snapshot was previously in use, switch to the latest snapshot associated with that branch.
110-
selector = .snapshot(branch: s.branch, date: nil)
111-
case .xcode:
112-
// Xcode will not be in the list of installed toolchains, so this is only here for completeness
113-
selector = .xcode
114-
}
115-
116-
if let toUse = config.listInstalledToolchains(selector: selector)
117-
.filter({ !toolchains.contains($0) })
118-
.max()
119-
?? config.listInstalledToolchains(selector: .latest).filter({ !toolchains.contains($0) }).max()
120-
?? config.installedToolchains.filter({ !toolchains.contains($0) }).max()
121-
{
122-
let pathChanged = try await Use.execute(ctx, toUse, globalDefault: true, verbose: self.root.verbose, &config)
123-
if pathChanged {
124-
try await Self.handlePathChange(ctx)
125-
}
126-
} else {
127-
// If there are no more toolchains installed, just unuse the currently active toolchain.
128-
config.inUse = nil
129-
try config.save(ctx)
130-
}
238+
try await self.handleInUseToolchainReplacement(ctx, toolchain, sortedToolchains, &config)
131239
}
132240

133241
try await Self.execute(ctx, toolchain, &config, verbose: self.root.verbose)
134242
}
135243

244+
await self.displayCompletionMessage(ctx, sortedToolchains.count)
245+
}
246+
247+
private func applySortingStrategy(_ toolchains: [ToolchainVersion], config: Config) -> [ToolchainVersion] {
248+
toolchains.sorted { a, b in
249+
a != config.inUse && (b == config.inUse || a < b)
250+
}
251+
}
252+
253+
private func handleInUseToolchainReplacement(
254+
_ ctx: SwiftlyCoreContext,
255+
_ toolchain: ToolchainVersion,
256+
_ allUninstallTargets: [ToolchainVersion],
257+
_ config: inout Config
258+
) async throws {
259+
let replacementSelector = self.createReplacementSelector(for: toolchain)
260+
261+
if let replacement = self.findSuitableReplacement(config, replacementSelector, excluding: allUninstallTargets) {
262+
let pathChanged = try await Use.execute(ctx, replacement, globalDefault: true, verbose: self.root.verbose, &config)
263+
if pathChanged {
264+
try await Self.handlePathChange(ctx)
265+
}
266+
} else {
267+
config.inUse = nil
268+
try config.save(ctx)
269+
}
270+
}
271+
272+
private func createReplacementSelector(for toolchain: ToolchainVersion) -> ToolchainSelector {
273+
switch toolchain {
274+
case let .stable(sr):
275+
// If a.b.c was previously in use, switch to the latest a.b toolchain.
276+
return .stable(major: sr.major, minor: sr.minor, patch: nil)
277+
case let .snapshot(s):
278+
// If a snapshot was previously in use, switch to the latest snapshot associated with that branch.
279+
return .snapshot(branch: s.branch, date: nil)
280+
case .xcode:
281+
// Xcode will not be in the list of installed toolchains, so this is only here for completeness
282+
return .xcode
283+
}
284+
}
285+
286+
private func findSuitableReplacement(
287+
_ config: Config,
288+
_ selector: ToolchainSelector,
289+
excluding: [ToolchainVersion]
290+
) -> ToolchainVersion? {
291+
// Try the specific selector first
292+
if let replacement = config.listInstalledToolchains(selector: selector)
293+
.filter({ !excluding.contains($0) })
294+
.max()
295+
{
296+
return replacement
297+
}
298+
299+
// Try latest stable as fallback, but only if there are stable toolchains
300+
let stableToolchains = config.installedToolchains.filter { $0.isStableRelease() && !excluding.contains($0) }
301+
if !stableToolchains.isEmpty {
302+
return stableToolchains.max()
303+
}
304+
305+
// Finally, try any remaining toolchain
306+
return config.installedToolchains.filter { !excluding.contains($0) }.max()
307+
}
308+
309+
private func displayProgress(_ ctx: SwiftlyCoreContext, index: Int, total: Int, toolchain: ToolchainVersion) async {
310+
if total > 1 {
311+
await ctx.message("[\(index + 1)/\(total)] Processing \(toolchain)")
312+
}
313+
}
314+
315+
private func displayCompletionMessage(_ ctx: SwiftlyCoreContext, _ toolchainCount: Int) async {
136316
await ctx.message()
137-
await ctx.message("\(toolchains.count) toolchain(s) successfully uninstalled")
317+
if self.toolchains.count == 1 {
318+
await ctx.message("\(toolchainCount) toolchain(s) successfully uninstalled")
319+
} else {
320+
await ctx.message("Successfully uninstalled \(toolchainCount) toolchain(s) from \(self.toolchains.count) selector(s)")
321+
}
138322
}
139323

140324
static func execute(

0 commit comments

Comments
 (0)