diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 74c0c19b..902844a2 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -82,7 +82,10 @@ struct Install: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try await validateSwiftly(ctx) + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } var config = try await Config.load(ctx) diff --git a/Sources/Swiftly/List.swift b/Sources/Swiftly/List.swift index 82563f06..a6212d71 100644 --- a/Sources/Swiftly/List.swift +++ b/Sources/Swiftly/List.swift @@ -38,7 +38,11 @@ struct List: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try await validateSwiftly(ctx) + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } + let selector = try self.toolchainSelector.map { input in try ToolchainSelector(parsing: input) } diff --git a/Sources/Swiftly/ListAvailable.swift b/Sources/Swiftly/ListAvailable.swift index e6f772d1..8e9a6224 100644 --- a/Sources/Swiftly/ListAvailable.swift +++ b/Sources/Swiftly/ListAvailable.swift @@ -44,7 +44,11 @@ struct ListAvailable: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try await validateSwiftly(ctx) + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } + let selector = try self.toolchainSelector.map { input in try ToolchainSelector(parsing: input) } diff --git a/Sources/Swiftly/Run.swift b/Sources/Swiftly/Run.swift index 5e4ca5db..8f8ff6d7 100644 --- a/Sources/Swiftly/Run.swift +++ b/Sources/Swiftly/Run.swift @@ -58,7 +58,10 @@ struct Run: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try await validateSwiftly(ctx) + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } // Handle the specific case where help is requested of the run subcommand if command == ["--help"] { diff --git a/Sources/Swiftly/SelfUpdate.swift b/Sources/Swiftly/SelfUpdate.swift index c28c3521..49a76e12 100644 --- a/Sources/Swiftly/SelfUpdate.swift +++ b/Sources/Swiftly/SelfUpdate.swift @@ -21,7 +21,7 @@ struct SelfUpdate: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try await validateSwiftly(ctx) + let _ = try await validateSwiftly(ctx) let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx) / "swiftly" guard try await fs.exists(atPath: swiftlyBin) else { diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 71794236..7acb55a8 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -93,7 +93,7 @@ extension Data { } extension SwiftlyCommand { - public mutating func validateSwiftly(_ ctx: SwiftlyCoreContext) async throws { + public mutating func validateSwiftly(_ ctx: SwiftlyCoreContext) async throws -> () -> Void { for requiredDir in Swiftly.requiredDirectories(ctx) { guard try await fs.exists(atPath: requiredDir) else { do { @@ -107,5 +107,27 @@ extension SwiftlyCommand { // Verify that the configuration exists and can be loaded _ = try await Config.load(ctx) + + let shouldUpdateSwiftly: Bool + if let swiftlyRelease = try? await ctx.httpClient.getCurrentSwiftlyRelease() { + shouldUpdateSwiftly = try swiftlyRelease.swiftlyVersion > SwiftlyCore.version + } else { + shouldUpdateSwiftly = false + } + + return { + if shouldUpdateSwiftly { + let updateMessage = """ + ----------------------------- + A new release of swiftly is available. + Please run `swiftly self-update` to update. + -----------------------------\n + """ + + if let data = updateMessage.data(using: .utf8) { + FileHandle.standardError.write(data) + } + } + } } } diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index 5a1c8332..75b9ff4f 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -48,7 +48,11 @@ struct Uninstall: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try await validateSwiftly(ctx) + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } + let startingConfig = try await Config.load(ctx) let toolchains: [ToolchainVersion] diff --git a/Sources/Swiftly/Update.swift b/Sources/Swiftly/Update.swift index 38cf4c1c..d550e3a5 100644 --- a/Sources/Swiftly/Update.swift +++ b/Sources/Swiftly/Update.swift @@ -83,7 +83,11 @@ struct Update: SwiftlyCommand { } public mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try await validateSwiftly(ctx) + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } + var config = try await Config.load(ctx) guard let parameters = try await self.resolveUpdateParameters(ctx, &config) else { diff --git a/Sources/Swiftly/Use.swift b/Sources/Swiftly/Use.swift index 17b0caeb..efde6791 100644 --- a/Sources/Swiftly/Use.swift +++ b/Sources/Swiftly/Use.swift @@ -60,7 +60,11 @@ struct Use: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try await validateSwiftly(ctx) + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } + var config = try await Config.load(ctx) // This is the bare use command where we print the selected toolchain version (or the path to it) diff --git a/Tests/SwiftlyTests/ListTests.swift b/Tests/SwiftlyTests/ListTests.swift index d1d28ee0..5489bb21 100644 --- a/Tests/SwiftlyTests/ListTests.swift +++ b/Tests/SwiftlyTests/ListTests.swift @@ -19,17 +19,21 @@ import Testing .oldReleaseSnapshot, ] + private static let swiftlyVersion = SwiftlyVersion(major: SwiftlyCore.version.major, minor: 0, patch: 0) + /// Constructs a mock home directory with the toolchains listed above installed and runs the provided closure within /// the context of that home. func runListTest(f: () async throws -> Void) async throws { try await SwiftlyTests.withTestHome(name: Self.homeName) { - for toolchain in Set.allToolchains() { - try await SwiftlyTests.installMockedToolchain(toolchain: toolchain) - } + try await SwiftlyTests.withMockedSwiftlyVersion(latestSwiftlyVersion: Self.swiftlyVersion) { + for toolchain in Set.allToolchains() { + try await SwiftlyTests.installMockedToolchain(toolchain: toolchain) + } - try await SwiftlyTests.runCommand(Use.self, ["use", "latest"]) + try await SwiftlyTests.runCommand(Use.self, ["use", "latest"]) - try await f() + try await f() + } } } @@ -155,16 +159,18 @@ import Testing /// Tests that `list` properly handles the case where no toolchains have been installed yet. @Test(.testHome(Self.homeName)) func listEmpty() async throws { - var toolchains = try await self.runList(selector: nil) - #expect(toolchains == []) + try await SwiftlyTests.withMockedSwiftlyVersion(latestSwiftlyVersion: Self.swiftlyVersion) { + var toolchains = try await self.runList(selector: nil) + #expect(toolchains == []) - toolchains = try await self.runList(selector: "5") - #expect(toolchains == []) + toolchains = try await self.runList(selector: "5") + #expect(toolchains == []) - toolchains = try await self.runList(selector: "main-snapshot") - #expect(toolchains == []) + toolchains = try await self.runList(selector: "main-snapshot") + #expect(toolchains == []) - toolchains = try await self.runList(selector: "5.7-snapshot") - #expect(toolchains == []) + toolchains = try await self.runList(selector: "5.7-snapshot") + #expect(toolchains == []) + } } } diff --git a/Tests/SwiftlyTests/PlatformTests.swift b/Tests/SwiftlyTests/PlatformTests.swift index 684c8901..7354e44b 100644 --- a/Tests/SwiftlyTests/PlatformTests.swift +++ b/Tests/SwiftlyTests/PlatformTests.swift @@ -18,7 +18,7 @@ import Testing return (mockedToolchainFile, version, tmpDir) } - @Test(.testHome()) func install() async throws { + @Test(.testHome(), .mockedSwiftlyVersion()) func install() async throws { // GIVEN: a toolchain has been downloaded var (mockedToolchainFile, version, tmpDir) = try await self.mockToolchainDownload(version: "5.7.1") var cleanup = [tmpDir] @@ -53,7 +53,7 @@ import Testing #expect(2 == toolchains.count) } - @Test(.testHome()) func uninstall() async throws { + @Test(.testHome(), .mockedSwiftlyVersion()) func uninstall() async throws { // GIVEN: toolchains have been downloaded, and installed var (mockedToolchainFile, version, tmpDir) = try await self.mockToolchainDownload(version: "5.8.0") var cleanup = [tmpDir] @@ -89,6 +89,7 @@ import Testing #if os(macOS) || os(Linux) @Test( + .mockedSwiftlyVersion(), .mockHomeToolchains(), arguments: [ "/a/b/c:SWIFTLY_BIN_DIR:/d/e/f", diff --git a/Tests/SwiftlyTests/RunTests.swift b/Tests/SwiftlyTests/RunTests.swift index c22d4912..bf995b9c 100644 --- a/Tests/SwiftlyTests/RunTests.swift +++ b/Tests/SwiftlyTests/RunTests.swift @@ -8,7 +8,7 @@ import Testing static let homeName = "runTests" /// Tests that the `run` command can switch between installed toolchains. - @Test(.mockHomeToolchains()) func runSelection() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func runSelection() async throws { // GIVEN: a set of installed toolchains // WHEN: invoking the run command with a selector argument for that toolchain var output = try await SwiftlyTests.runWithMockedIO(Run.self, ["run", "swift", "--version", "+\(ToolchainVersion.newStable.name)"]) @@ -35,7 +35,7 @@ import Testing } /// Tests the `run` command verifying that the environment is as expected - @Test(.mockHomeToolchains()) func runEnvironment() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func runEnvironment() async throws { // The toolchains directory should be the fist entry on the path let output = try await SwiftlyTests.runWithMockedIO(Run.self, ["run", try await Swiftly.currentPlatform.getShell(), "-c", "echo $PATH"]) #expect(output.count == 1) diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index e74ee1a9..cd93a7e7 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -142,6 +142,23 @@ extension Trait where Self == TestHomeTrait { static func testHome(_ name: String = "testHome") -> Self { Self(name) } } +// extension Trait for mockedSwiftlyVersion +struct MockedSwiftlyVersionTrait: TestTrait, TestScoping { + var name: String = "testHome" + + init(_ name: String) { self.name = name } + + func provideScope(for _: Test, testCase _: Test.Case?, performing function: @Sendable () async throws -> Void) async throws { + try await SwiftlyTests.withMockedSwiftlyVersion(latestSwiftlyVersion: SwiftlyVersion(major: SwiftlyCore.version.major, minor: 0, patch: 0)) { + try await function() + } + } +} + +extension Trait where Self == MockedSwiftlyVersionTrait { + static func mockedSwiftlyVersion(_ name: String = "testHome") -> Self { Self(name) } +} + struct MockHomeToolchainsTrait: TestTrait, TestScoping { var name: String = "testHome" var toolchains: Set = .allToolchains() diff --git a/Tests/SwiftlyTests/UninstallTests.swift b/Tests/SwiftlyTests/UninstallTests.swift index d1aa79dc..d18004ff 100644 --- a/Tests/SwiftlyTests/UninstallTests.swift +++ b/Tests/SwiftlyTests/UninstallTests.swift @@ -7,7 +7,7 @@ import Testing static let homeName = "uninstallTests" /// Tests that `swiftly uninstall` successfully handles being invoked when no toolchains have been installed yet. - @Test(.mockHomeToolchains(Self.homeName, toolchains: [])) func uninstallNoInstalledToolchains() async throws { + @Test(.mockHomeToolchains(Self.homeName, toolchains: []), .mockedSwiftlyVersion()) func uninstallNoInstalledToolchains() async throws { _ = try await SwiftlyTests.runWithMockedIO(Uninstall.self, ["uninstall", "1.2.3"], input: ["y"]) try await SwiftlyTests.validateInstalledToolchains( @@ -17,7 +17,7 @@ import Testing } /// Tests that `swiftly uninstall latest` successfully uninstalls the latest stable release of Swift. - @Test func uninstallLatest() async throws { + @Test(.mockedSwiftlyVersion()) func uninstallLatest() async throws { let toolchains = Set.allToolchains().filter { $0.asStableRelease != nil } try await SwiftlyTests.withMockedHome(homeName: Self.homeName, toolchains: toolchains) { var installed = toolchains @@ -38,7 +38,7 @@ import Testing } /// Tests that a fully-qualified stable release version can be supplied to `swiftly uninstall`. - @Test(.mockHomeToolchains(Self.homeName)) func uninstallStableRelease() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName)) func uninstallStableRelease() async throws { var installed: Set = .allToolchains() for toolchain in Set.allToolchains().filter({ $0.isStableRelease() }) { @@ -60,7 +60,7 @@ import Testing } /// Tests that a fully-qualified snapshot version can be supplied to `swiftly uninstall`. - @Test(.mockHomeToolchains(Self.homeName)) func uninstallSnapshot() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName)) func uninstallSnapshot() async throws { var installed: Set = .allToolchains() for toolchain in Set.allToolchains().filter({ $0.isSnapshot() }) { @@ -82,7 +82,7 @@ import Testing } /// Tests that multiple toolchains can be installed at once. - @Test func bulkUninstall() async throws { + @Test(.mockedSwiftlyVersion()) func bulkUninstall() async throws { let toolchains = Set( [ "main-snapshot-2022-01-03", @@ -158,7 +158,7 @@ import Testing } /// Tests that uninstalling the toolchain that is currently "in use" has the expected behavior. - @Test func uninstallInUse() async throws { + @Test(.mockedSwiftlyVersion()) func uninstallInUse() async throws { let toolchains: Set = [ .oldStable, .oldStableNewPatch, @@ -224,7 +224,7 @@ import Testing } /// Tests that uninstalling the last toolchain is handled properly and cleans up any symlinks. - @Test(.mockHomeToolchains(Self.homeName, toolchains: [.oldStable])) func uninstallLastToolchain() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable])) func uninstallLastToolchain() async throws { _ = try await SwiftlyTests.runWithMockedIO(Uninstall.self, ["uninstall", ToolchainVersion.oldStable.name], input: ["y"]) let config = try await Config.load() #expect(config.inUse == nil) @@ -237,7 +237,7 @@ import Testing } /// Tests that aborting an uninstall works correctly. - @Test(.mockHomeToolchains(Self.homeName, toolchains: .allToolchains(), inUse: .oldStable)) func uninstallAbort() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: .allToolchains(), inUse: .oldStable)) func uninstallAbort() async throws { let preConfig = try await Config.load() _ = try await SwiftlyTests.runWithMockedIO(Uninstall.self, ["uninstall", ToolchainVersion.oldStable.name], input: ["n"]) try await SwiftlyTests.validateInstalledToolchains( @@ -250,7 +250,7 @@ import Testing } /// Tests that providing the `-y` argument skips the confirmation prompt. - @Test(.mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable])) func uninstallAssumeYes() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable])) func uninstallAssumeYes() async throws { try await SwiftlyTests.runCommand(Uninstall.self, ["uninstall", "-y", ToolchainVersion.oldStable.name]) try await SwiftlyTests.validateInstalledToolchains( [.newStable], @@ -259,7 +259,7 @@ import Testing } /// Tests that providing "all" as an argument to uninstall will uninstall all toolchains. - @Test(.mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .newMainSnapshot, .oldReleaseSnapshot])) func uninstallAll() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .newMainSnapshot, .oldReleaseSnapshot])) func uninstallAll() async throws { try await SwiftlyTests.runCommand(Uninstall.self, ["uninstall", "-y", "all"]) try await SwiftlyTests.validateInstalledToolchains( [], @@ -268,7 +268,7 @@ import Testing } /// Tests that uninstalling a toolchain that is the global default, but is not in the list of installed toolchains. - @Test(.mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .newMainSnapshot, .oldReleaseSnapshot])) func uninstallNotInstalled() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .newMainSnapshot, .oldReleaseSnapshot])) func uninstallNotInstalled() async throws { var config = try await Config.load() config.inUse = .newMainSnapshot config.installedToolchains.remove(.newMainSnapshot) diff --git a/Tests/SwiftlyTests/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index bfc6c367..9297f074 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -16,7 +16,7 @@ import Testing } /// Tests that the `use` command can switch between installed stable release toolchains. - @Test(.mockHomeToolchains()) func useStable() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useStable() async throws { try await self.useAndValidate(argument: ToolchainVersion.oldStable.name, expectedVersion: .oldStable) try await self.useAndValidate(argument: ToolchainVersion.newStable.name, expectedVersion: .newStable) try await self.useAndValidate(argument: ToolchainVersion.newStable.name, expectedVersion: .newStable) @@ -24,7 +24,7 @@ import Testing /// Tests that that "latest" can be provided to the `use` command to select the installed stable release /// toolchain with the most recent version. - @Test(.mockHomeToolchains()) func useLatestStable() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useLatestStable() async throws { // Use an older toolchain. try await self.useAndValidate(argument: ToolchainVersion.oldStable.name, expectedVersion: .oldStable) @@ -43,7 +43,7 @@ import Testing /// Tests that the latest installed patch release toolchain for a given major/minor version pair can be selected by /// omitting the patch version (e.g. `use 5.6`). - @Test(.mockHomeToolchains()) func useLatestStablePatch() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useLatestStablePatch() async throws { try await self.useAndValidate(argument: ToolchainVersion.oldStable.name, expectedVersion: .oldStable) let oldStableVersion = ToolchainVersion.oldStable.asStableRelease! @@ -71,7 +71,7 @@ import Testing } /// Tests that the `use` command can switch between installed main snapshot toolchains. - @Test(.mockHomeToolchains()) func useMainSnapshot() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useMainSnapshot() async throws { // Switch to a non-snapshot. try await self.useAndValidate(argument: ToolchainVersion.newStable.name, expectedVersion: .newStable) try await self.useAndValidate(argument: ToolchainVersion.oldMainSnapshot.name, expectedVersion: .oldMainSnapshot) @@ -83,7 +83,7 @@ import Testing /// Tests that the latest installed main snapshot toolchain can be selected by omitting the /// date (e.g. `use main-snapshot`). - @Test(.mockHomeToolchains()) func useLatestMainSnapshot() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useLatestMainSnapshot() async throws { // Switch to a non-snapshot. try await self.useAndValidate(argument: ToolchainVersion.newStable.name, expectedVersion: .newStable) // Switch to the latest main snapshot. @@ -97,7 +97,7 @@ import Testing } /// Tests that the `use` command can switch between installed release snapshot toolchains. - @Test(.mockHomeToolchains()) func useReleaseSnapshot() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useReleaseSnapshot() async throws { // Switch to a non-snapshot. try await self.useAndValidate(argument: ToolchainVersion.newStable.name, expectedVersion: .newStable) try await self.useAndValidate( @@ -121,7 +121,7 @@ import Testing /// Tests that the latest installed release snapshot toolchain can be selected by omitting the /// date (e.g. `use 5.7-snapshot`). - @Test(.mockHomeToolchains()) func useLatestReleaseSnapshot() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useLatestReleaseSnapshot() async throws { // Switch to a non-snapshot. try await self.useAndValidate(argument: ToolchainVersion.newStable.name, expectedVersion: .newStable) // Switch to the latest snapshot for the given release. @@ -150,7 +150,7 @@ import Testing } /// Tests that the `use` command gracefully exits when executed before any toolchains have been installed. - @Test(.mockHomeToolchains(toolchains: [])) func useNoInstalledToolchains() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains(toolchains: [])) func useNoInstalledToolchains() async throws { try await SwiftlyTests.runCommand(Use.self, ["use", "-g", "latest"]) var config = try await Config.load() @@ -163,7 +163,7 @@ import Testing } /// Tests that the `use` command gracefully handles being executed with toolchain names that haven't been installed. - @Test(.mockHomeToolchains()) func useNonExistent() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useNonExistent() async throws { // Switch to a valid toolchain. try await self.useAndValidate(argument: ToolchainVersion.oldStable.name, expectedVersion: .oldStable) @@ -175,7 +175,7 @@ import Testing } /// Tests that the `use` command works with all the installed toolchains in this test harness. - @Test(.mockHomeToolchains()) func useAll() async throws { + @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func useAll() async throws { let config = try await Config.load() for toolchain in config.installedToolchains { @@ -187,7 +187,7 @@ import Testing } /// Tests that running a use command without an argument prints the currently in-use toolchain. - @Test func printInUse() async throws { + @Test(.mockedSwiftlyVersion()) func printInUse() async throws { let toolchains = [ ToolchainVersion.newStable, .newMainSnapshot, @@ -209,7 +209,7 @@ import Testing } /// Tests in-use toolchain selected by the .swift-version file. - @Test func swiftVersionFile() async throws { + @Test(.mockedSwiftlyVersion()) func swiftVersionFile() async throws { let toolchains = [ ToolchainVersion.newStable, .newMainSnapshot,