From 59bc9777881f15ea7fee54bddea8a89b98b49525 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sat, 21 Dec 2024 00:07:01 -0500 Subject: [PATCH] feat: Updates dependencies and cli commands to be async. --- Sources/CliVersion/CliClient.swift | 151 ++++++++++---------- Sources/CliVersion/FileClient.swift | 32 +++-- Sources/CliVersion/GitVersionClient.swift | 55 +++---- Sources/TestSupport/TestSupport.swift | 6 +- Sources/cli-version/BuildCommand.swift | 10 +- Sources/cli-version/CliVersionCommand.swift | 20 +-- Sources/cli-version/GenerateCommand.swift | 6 +- Sources/cli-version/UpdateCommand.swift | 14 +- Tests/CliVersionTests/CliClientTests.swift | 19 ++- Tests/CliVersionTests/CliVersionTests.swift | 67 ++++----- 10 files changed, 185 insertions(+), 195 deletions(-) diff --git a/Sources/CliVersion/CliClient.swift b/Sources/CliVersion/CliClient.swift index 25331e2..05e7331 100644 --- a/Sources/CliVersion/CliClient.swift +++ b/Sources/CliVersion/CliClient.swift @@ -21,6 +21,7 @@ public struct CliClient: Sendable { /// Build and update the version based on the git tag, or branch + sha. public var build: @Sendable (SharedOptions) async throws -> String + /// Bump the existing version. public var bump: @Sendable (BumpOption, SharedOptions) async throws -> String /// Generate a version file with an optional version that can be set manually. @@ -63,10 +64,10 @@ extension CliClient: DependencyKey { public static func live(environment: [String: String]) -> Self { .init( - build: { try $0.build(environment) }, - bump: { try $1.bump($0) }, - generate: { try $0.generate() }, - update: { try $0.update() } + build: { try await $0.build(environment) }, + bump: { try await $1.bump($0) }, + generate: { try await $0.generate() }, + update: { try await $0.update() } ) } @@ -97,20 +98,20 @@ public extension CliClient.SharedOptions { @discardableResult func run( - _ operation: () throws -> T - ) rethrows -> T { - try withDependencies { + _ operation: () async throws -> T + ) async rethrows -> T { + try await withDependencies { $0.logger.logLevel = .init(verbose: verbose) } operation: { - try operation() + try await operation() } } } private extension CliClient.SharedOptions { - func build(_ environment: [String: String]) throws -> String { - try run { + func build(_ environment: [String: String]) async throws -> String { + try await run { @Dependency(\.gitVersionClient) var gitVersion @Dependency(\.fileClient) var fileClient @Dependency(\.logger) var logger @@ -126,91 +127,95 @@ private extension CliClient.SharedOptions { let fileUrl = self.fileUrl logger.debug("File url: \(fileUrl.cleanFilePath)") - let currentVersion = try gitVersion.currentVersion(in: gitDirectory) + let currentVersion = try await gitVersion.currentVersion(in: gitDirectory) let fileContents = Template.build(currentVersion) - try fileClient.write(string: fileContents, to: fileUrl) + try await fileClient.write(string: fileContents, to: fileUrl) return fileUrl.cleanFilePath } } - func bump(_ type: CliClient.BumpOption) throws -> String { - @Dependency(\.fileClient) var fileClient - @Dependency(\.logger) var logger + func bump(_ type: CliClient.BumpOption) async throws -> String { + try await run { + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger - let targetUrl = fileUrl + let targetUrl = fileUrl - logger.debug("Bump target url: \(targetUrl.cleanFilePath)") + logger.debug("Bump target url: \(targetUrl.cleanFilePath)") - let contents = try fileClient.read(fileUrl.cleanFilePath) - let versionLine = contents.split(separator: "\n") - .first { $0.hasPrefix("let VERSION:") } + let contents = try await fileClient.read(fileUrl) + let versionLine = contents.split(separator: "\n") + .first { $0.hasPrefix("let VERSION:") } - guard let versionLine else { - throw CliClientError.failedToParseVersionFile + guard let versionLine else { + throw CliClientError.failedToParseVersionFile + } + + let isOptional = versionLine.contains("String?") + let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last + guard let versionString else { + throw CliClientError.failedToParseVersionFile + } + + let parts = String(versionString).split(separator: ".") + logger.debug("Version parts: \(parts)") + + // TODO: Better error. + guard parts.count == 3 else { + throw CliClientError.failedToParseVersionFile + } + + var major = Int(String(parts[0])) ?? 0 + var minor = Int(String(parts[1])) ?? 0 + var patch = Int(String(parts[2])) ?? 0 + + type.bump(major: &major, minor: &minor, patch: &patch) + + let version = "\(major).\(minor).\(patch)" + logger.debug("Bumped version: \(version)") + + let template = isOptional ? Template.optional(version) : Template.build(version) + + if !dryRun { + try await fileClient.write(string: template, to: targetUrl) + } else { + logger.debug("Skipping, due to dry-run being passed.") + } + + return targetUrl.cleanFilePath } - - let isOptional = versionLine.contains("String?") - let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last - guard let versionString else { - throw CliClientError.failedToParseVersionFile - } - - let parts = String(versionString).split(separator: ".") - logger.debug("Version parts: \(parts)") - - // TODO: Better error. - guard parts.count == 3 else { - throw CliClientError.failedToParseVersionFile - } - - var major = Int(String(parts[0])) ?? 0 - var minor = Int(String(parts[1])) ?? 0 - var patch = Int(String(parts[2])) ?? 0 - - type.bump(major: &major, minor: &minor, patch: &patch) - - let version = "\(major).\(minor).\(patch)" - logger.debug("Bumped version: \(version)") - - let template = isOptional ? Template.optional(version) : Template.build(version) - - if !dryRun { - try fileClient.write(string: template, to: targetUrl) - } else { - logger.debug("Skipping, due to dry-run being passed.") - } - - return targetUrl.cleanFilePath } - func generate(_ version: String? = nil) throws -> String { - @Dependency(\.fileClient) var fileClient - @Dependency(\.logger) var logger + func generate(_ version: String? = nil) async throws -> String { + try await run { + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger - let targetUrl = fileUrl + let targetUrl = fileUrl - logger.debug("Generate target url: \(targetUrl.cleanFilePath)") + logger.debug("Generate target url: \(targetUrl.cleanFilePath)") - guard !fileClient.fileExists(targetUrl) else { - throw CliClientError.fileExists(path: targetUrl.cleanFilePath) + guard !fileClient.fileExists(targetUrl) else { + throw CliClientError.fileExists(path: targetUrl.cleanFilePath) + } + + let template = Template.optional(version) + + if !dryRun { + try await fileClient.write(string: template, to: targetUrl) + } else { + logger.debug("Skipping, due to dry-run being passed.") + } + return targetUrl.cleanFilePath } - - let template = Template.optional(version) - - if !dryRun { - try fileClient.write(string: template, to: targetUrl) - } else { - logger.debug("Skipping, due to dry-run being passed.") - } - return targetUrl.cleanFilePath } - func update() throws -> String { + func update() async throws -> String { @Dependency(\.gitVersionClient) var gitVersionClient - return try generate(gitVersionClient.currentVersion(in: gitDirectory)) + return try await generate(gitVersionClient.currentVersion(in: gitDirectory)) } } diff --git a/Sources/CliVersion/FileClient.swift b/Sources/CliVersion/FileClient.swift index cc8843a..233b458 100644 --- a/Sources/CliVersion/FileClient.swift +++ b/Sources/CliVersion/FileClient.swift @@ -6,6 +6,8 @@ import Foundation #endif import XCTestDynamicOverlay +// TODO: Need a capturing version on write for tests. + public extension DependencyValues { /// Access a basic ``FileClient`` that can read / write data to the file system. @@ -27,20 +29,21 @@ public extension DependencyValues { @DependencyClient public struct FileClient: Sendable { + /// Check if a file exists at the given url. public var fileExists: @Sendable (URL) -> Bool = { _ in true } /// Read the contents of a file. - public var read: @Sendable (URL) throws -> String + public var read: @Sendable (URL) async throws -> String /// Write `Data` to a file `URL`. - public var write: @Sendable (Data, URL) throws -> Void + public var write: @Sendable (Data, URL) async throws -> Void /// Read the contents of a file at the given path. /// /// - Parameters: /// - path: The file path to read from. - public func read(_ path: String) throws -> String { - try read(url(for: path)) + public func read(_ path: String) async throws -> String { + try await read(url(for: path)) } /// Write's the the string to a file path. @@ -48,9 +51,8 @@ public struct FileClient: Sendable { /// - Parameters: /// - string: The string to write to the file. /// - path: The file path. - public func write(string: String, to path: String) throws { - let url = url(for: path) - try write(string: string, to: url) + public func write(string: String, to path: String) async throws { + try await write(string: string, to: url(for: path)) } /// Write's the the string to a file path. @@ -58,8 +60,8 @@ public struct FileClient: Sendable { /// - Parameters: /// - string: The string to write to the file. /// - url: The file url. - public func write(string: String, to url: URL) throws { - try write(Data(string.utf8), url) + public func write(string: String, to url: URL) async throws { + try await write(Data(string.utf8), url) } } @@ -83,3 +85,15 @@ extension FileClient: DependencyKey { ) } + +private actor CapturingWrite { + var data: Data? + var url: URL? + + init() {} + + func set(data: Data, url: URL) { + self.data = data + self.url = url + } +} diff --git a/Sources/CliVersion/GitVersionClient.swift b/Sources/CliVersion/GitVersionClient.swift index 888bd74..3801c99 100644 --- a/Sources/CliVersion/GitVersionClient.swift +++ b/Sources/CliVersion/GitVersionClient.swift @@ -2,6 +2,8 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import Dependencies +import DependenciesMacros import ShellClient import XCTestDynamicOverlay @@ -14,57 +16,32 @@ import XCTestDynamicOverlay /// that is supplied with this library. The use case is to set the version of a command line /// tool based on the current git tag. /// -public struct GitVersionClient { +@DependencyClient +public struct GitVersionClient: Sendable { /// The closure to run that returns the current version from a given /// git directory. - private var currentVersion: (String?) throws -> String - - /// Create a new ``GitVersionClient`` instance. - /// - /// This is normally not interacted with directly, instead access the client through the dependency system. - /// ```swift - /// @Dependency(\.gitVersionClient) - /// ``` - /// - /// - Parameters: - /// - currentVersion: The closure that returns the current version. - /// - public init(currentVersion: @escaping (String?) throws -> String) { - self.currentVersion = currentVersion - } + public var currentVersion: @Sendable (String?) async throws -> String /// Get the current version from the `git tag` in the given directory. /// If a directory is not passed in, then we will use the current working directory. /// /// - Parameters: /// - gitDirectory: The directory to run the command in. - public func currentVersion(in gitDirectory: String? = nil) throws -> String { - try currentVersion(gitDirectory) - } - - /// Override the `currentVersion` command and return the passed in version string. - /// - /// This is useful for testing purposes. - /// - /// - Parameters: - /// - version: The version string to return when `currentVersion` is called. - public mutating func override(with version: String) { - currentVersion = { _ in version } + public func currentVersion(in gitDirectory: String? = nil) async throws -> String { + try await currentVersion(gitDirectory) } } extension GitVersionClient: TestDependencyKey { /// The ``GitVersionClient`` used in test / debug builds. - public static let testValue = GitVersionClient( - currentVersion: unimplemented("\(Self.self).currentVersion", placeholder: "") - ) + public static let testValue = GitVersionClient() /// The ``GitVersionClient`` used in release builds. public static var liveValue: GitVersionClient { .init(currentVersion: { gitDirectory in - try GitVersion(workingDirectory: gitDirectory).currentVersion() + try await GitVersion(workingDirectory: gitDirectory).currentVersion() }) } } @@ -97,19 +74,19 @@ public extension ShellCommand { private struct GitVersion { @Dependency(\.logger) var logger: Logger - @Dependency(\.shellClient) var shell: ShellClient + @Dependency(\.asyncShellClient) var shell let workingDirectory: String? - func currentVersion() throws -> String { + func currentVersion() async throws -> String { logger.debug("\("Fetching current version".bold)") do { logger.debug("Checking for tag.") - return try run(command: command(for: .describe)) + return try await run(command: command(for: .describe)) } catch { logger.debug("\("No tag found, deferring to branch & git sha".red)") - let branch = try run(command: command(for: .branch)) - let commit = try run(command: command(for: .commit)) + let branch = try await run(command: command(for: .branch)) + let commit = try await run(command: command(for: .commit)) return "\(branch) \(commit)" } } @@ -125,8 +102,8 @@ private struct GitVersion { } private extension GitVersion { - func run(command: ShellCommand) throws -> String { - try shell.background(command, trimmingCharactersIn: .whitespacesAndNewlines) + func run(command: ShellCommand) async throws -> String { + try await shell.background(command, trimmingCharactersIn: .whitespacesAndNewlines) } enum VersionArgs { diff --git a/Sources/TestSupport/TestSupport.swift b/Sources/TestSupport/TestSupport.swift index 04a0380..4790a00 100644 --- a/Sources/TestSupport/TestSupport.swift +++ b/Sources/TestSupport/TestSupport.swift @@ -9,14 +9,14 @@ import Foundation /// - Parameters: /// - operation: The operation to run with the temporary directory. public func withTemporaryDirectory( - _ operation: (URL) throws -> Void -) rethrows { + _ operation: (URL) async throws -> Void +) async rethrows { let tempUrl = FileManager.default .temporaryDirectory .appendingPathComponent(UUID().uuidString) try! FileManager.default.createDirectory(at: tempUrl, withIntermediateDirectories: false) - try operation(tempUrl) + try await operation(tempUrl) try! FileManager.default.removeItem(at: tempUrl) } diff --git a/Sources/cli-version/BuildCommand.swift b/Sources/cli-version/BuildCommand.swift index 880f728..6ad0842 100644 --- a/Sources/cli-version/BuildCommand.swift +++ b/Sources/cli-version/BuildCommand.swift @@ -4,7 +4,7 @@ import Foundation import ShellClient extension CliVersionCommand { - struct Build: ParsableCommand { + struct Build: AsyncParsableCommand { static var configuration: CommandConfiguration = .init( abstract: "Used for the build with version plugin.", discussion: "This should generally not be interacted with directly, outside of the build plugin." @@ -19,8 +19,8 @@ extension CliVersionCommand { var gitDirectory: String // TODO: Use CliClient - func run() throws { - try withDependencies { + func run() async throws { + try await withDependencies { $0.logger.logLevel = .debug $0.fileClient = .liveValue $0.gitVersionClient = .liveValue @@ -37,12 +37,12 @@ extension CliVersionCommand { let fileString = fileUrl.fileString() logger.info("File Url: \(fileString)") - let currentVersion = try gitVersion.currentVersion(in: gitDirectory) + let currentVersion = try await gitVersion.currentVersion(in: gitDirectory) let fileContents = buildTemplate .replacingOccurrences(of: "nil", with: "\"\(currentVersion)\"") - try fileClient.write(string: fileContents, to: fileUrl) + try await fileClient.write(string: fileContents, to: fileUrl) logger.info("Updated version file: \(fileString)") } } diff --git a/Sources/cli-version/CliVersionCommand.swift b/Sources/cli-version/CliVersionCommand.swift index 94f92c9..5e180b6 100644 --- a/Sources/cli-version/CliVersionCommand.swift +++ b/Sources/cli-version/CliVersionCommand.swift @@ -2,14 +2,14 @@ import ArgumentParser import Foundation @main -struct CliVersionCommand: ParsableCommand { - static var configuration: CommandConfiguration = .init( - commandName: "cli-version", - version: VERSION ?? "0.0.0", - subcommands: [ - Build.self, - Generate.self, - Update.self, - ] - ) +struct CliVersionCommand: AsyncParsableCommand { + static var configuration: CommandConfiguration = .init( + commandName: "cli-version", + version: VERSION ?? "0.0.0", + subcommands: [ + Build.self, + Generate.self, + Update.self + ] + ) } diff --git a/Sources/cli-version/GenerateCommand.swift b/Sources/cli-version/GenerateCommand.swift index 1b60050..61cbcb8 100644 --- a/Sources/cli-version/GenerateCommand.swift +++ b/Sources/cli-version/GenerateCommand.swift @@ -5,7 +5,7 @@ import Foundation import ShellClient extension CliVersionCommand { - struct Generate: ParsableCommand { + struct Generate: AsyncParsableCommand { static var configuration: CommandConfiguration = .init( abstract: "Generates a version file in a command line tool that can be set via the git tag or git sha.", discussion: "This command can be interacted with directly, outside of the plugin usage context.", @@ -15,7 +15,7 @@ extension CliVersionCommand { @OptionGroup var shared: SharedOptions // TODO: Use CliClient - func run() throws { + func run() async throws { @Dependency(\.logger) var logger: Logger @Dependency(\.fileClient) var fileClient @@ -30,7 +30,7 @@ extension CliVersionCommand { } if !shared.dryRun { - try fileClient.write(string: optionalTemplate, to: fileUrl) + try await fileClient.write(string: optionalTemplate, to: fileUrl) logger.info("Generated file at: \(fileString)") } else { logger.info("Would generate file at: \(fileString)") diff --git a/Sources/cli-version/UpdateCommand.swift b/Sources/cli-version/UpdateCommand.swift index c22ed73..ef94dd7 100644 --- a/Sources/cli-version/UpdateCommand.swift +++ b/Sources/cli-version/UpdateCommand.swift @@ -6,7 +6,7 @@ import ShellClient extension CliVersionCommand { - struct Update: ParsableCommand { + struct Update: AsyncParsableCommand { static var configuration: CommandConfiguration = .init( abstract: "Updates a version string to the git tag or git sha.", discussion: "This command can be interacted with directly outside of the plugin context." @@ -21,17 +21,17 @@ extension CliVersionCommand { var gitDirectory: String? // TODO: Use CliClient - func run() throws { - try withDependencies { + func run() async throws { + try await withDependencies { $0.logger.logLevel = shared.verbose ? .debug : .info $0.fileClient = .liveValue $0.gitVersionClient = .liveValue - $0.shellClient = .liveValue + $0.asyncShellClient = .liveValue } operation: { @Dependency(\.gitVersionClient) var gitVersion @Dependency(\.fileClient) var fileClient @Dependency(\.logger) var logger - @Dependency(\.shellClient) var shell + @Dependency(\.asyncShellClient) var shell let targetUrl = parseTarget(shared.target) let fileUrl = targetUrl @@ -39,13 +39,13 @@ extension CliVersionCommand { let fileString = fileUrl.fileString() - let currentVersion = try gitVersion.currentVersion(in: gitDirectory) + let currentVersion = try await gitVersion.currentVersion(in: gitDirectory) let fileContents = optionalTemplate .replacingOccurrences(of: "nil", with: "\"\(currentVersion)\"") if !shared.dryRun { - try fileClient.write(string: fileContents, to: fileUrl) + try await fileClient.write(string: fileContents, to: fileUrl) logger.info("Updated version file: \(fileString)") } else { logger.info("Would update file contents to:") diff --git a/Tests/CliVersionTests/CliClientTests.swift b/Tests/CliVersionTests/CliClientTests.swift index 6d0f717..2131cce 100644 --- a/Tests/CliVersionTests/CliClientTests.swift +++ b/Tests/CliVersionTests/CliClientTests.swift @@ -8,7 +8,7 @@ import TestSupport struct CliClientTests { @Test( - arguments: TestTarget.testCases + arguments: TestArguments.testCases ) func testBuild(target: String) async throws { try await run { @@ -27,11 +27,12 @@ struct CliClientTests { } @Test( - arguments: CliClient.BumpOption.allCases + arguments: TestArguments.bumpCases ) - func bump(type: CliClient.BumpOption) async throws { + func bump(type: CliClient.BumpOption, optional: Bool) async throws { + let template = optional ? Template.optional("1.0.0") : Template.build("1.0.0") try await run { - $0.fileClient.read = { _ in Template.optional("1.0.0") } + $0.fileClient.read = { _ in template } } operation: { let client = CliClient.liveValue let output = try await client.bump( @@ -50,7 +51,7 @@ struct CliClientTests { } @Test( - arguments: TestTarget.testCases + arguments: TestArguments.testCases ) func generate(target: String) async throws { // let (stream, continuation) = AsyncStream.makeStream() @@ -68,7 +69,7 @@ struct CliClientTests { } @Test( - arguments: TestTarget.testCases + arguments: TestArguments.testCases ) func update(target: String) async throws { // let (stream, continuation) = AsyncStream.makeStream() @@ -101,6 +102,10 @@ struct CliClientTests { } } -enum TestTarget { +enum TestArguments { static let testCases = ["bar", "Sources/bar", "/Sources/bar", "./Sources/bar"] + static let bumpCases = CliClient.BumpOption.allCases.reduce(into: [(CliClient.BumpOption, Bool)]()) { + $0.append(($1, true)) + $0.append(($1, false)) + } } diff --git a/Tests/CliVersionTests/CliVersionTests.swift b/Tests/CliVersionTests/CliVersionTests.swift index 0361c4d..f979ba2 100644 --- a/Tests/CliVersionTests/CliVersionTests.swift +++ b/Tests/CliVersionTests/CliVersionTests.swift @@ -10,7 +10,7 @@ final class GitVersionTests: XCTestCase { withDependencies({ $0.logger.logLevel = .debug $0.logger = .liveValue - $0.shellClient = .liveValue + $0.asyncShellClient = .liveValue $0.gitVersionClient = .liveValue $0.fileClient = .liveValue }, operation: { @@ -26,67 +26,56 @@ final class GitVersionTests: XCTestCase { .cleanFilePath } - func test_overrides_work() throws { - try withDependencies { - $0.gitVersionClient.override(with: "blob") - } operation: { - @Dependency(\.gitVersionClient) var versionClient - - let version = try versionClient.currentVersion() - XCTAssertEqual(version, "blob") - } - } - - func test_live() throws { + func test_live() async throws { @Dependency(\.gitVersionClient) var versionClient: GitVersionClient - let version = try versionClient.currentVersion(in: gitDir) + let version = try await versionClient.currentVersion(in: gitDir) print("VERSION: \(version)") // can't really have a predictable result for the live client. XCTAssertNotEqual(version, "blob") } - func test_commands() throws { - @Dependency(\.shellClient) var shellClient: ShellClient + // func test_commands() throws { + // @Dependency(\.asyncShellClient) var shellClient: ShellClient + // + // XCTAssertNoThrow( + // try shellClient.background( + // .gitCurrentBranch(gitDirectory: gitDir), + // trimmingCharactersIn: .whitespacesAndNewlines + // ) + // ) + // + // XCTAssertNoThrow( + // try shellClient.background( + // .gitCurrentSha(gitDirectory: gitDir), + // trimmingCharactersIn: .whitespacesAndNewlines + // ) + // ) + // } - XCTAssertNoThrow( - try shellClient.background( - .gitCurrentBranch(gitDirectory: gitDir), - trimmingCharactersIn: .whitespacesAndNewlines - ) - ) - - XCTAssertNoThrow( - try shellClient.background( - .gitCurrentSha(gitDirectory: gitDir), - trimmingCharactersIn: .whitespacesAndNewlines - ) - ) - } - - func test_file_client() throws { - try withTemporaryDirectory { tmpDir in + func test_file_client() async throws { + try await withTemporaryDirectory { tmpDir in @Dependency(\.fileClient) var fileClient let filePath = tmpDir.appendingPathComponent("blob.txt") - try fileClient.write(string: "Blob", to: filePath) + try await fileClient.write(string: "Blob", to: filePath) - let contents = try fileClient.read(filePath) + let contents = try await fileClient.read(filePath) .trimmingCharacters(in: .whitespacesAndNewlines) XCTAssertEqual(contents, "Blob") } } - func test_file_client_with_string_path() throws { - try withTemporaryDirectory { tmpDir in + func test_file_client_with_string_path() async throws { + try await withTemporaryDirectory { tmpDir in @Dependency(\.fileClient) var fileClient let filePath = tmpDir.appendingPathComponent("blob.txt") let fileString = filePath.cleanFilePath - try fileClient.write(string: "Blob", to: fileString) + try await fileClient.write(string: "Blob", to: fileString) - let contents = try fileClient.read(fileString) + let contents = try await fileClient.read(fileString) .trimmingCharacters(in: .whitespacesAndNewlines) XCTAssertEqual(contents, "Blob")