From b7ac6ee9d14e9bb611e22807a9f7dea5d50fb993 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Fri, 20 Dec 2024 23:02:22 -0500 Subject: [PATCH] feat: Working on bump version. --- Package.resolved | 7 +- Package.swift | 4 +- Sources/CliVersion/CliClient.swift | 269 ++++++++++++--------- Sources/CliVersion/FileClient.swift | 2 +- Sources/CliVersion/GitVersionClient.swift | 2 +- Tests/CliVersionTests/CliClientTests.swift | 106 ++++++++ 6 files changed, 274 insertions(+), 116 deletions(-) create mode 100644 Tests/CliVersionTests/CliClientTests.swift diff --git a/Package.resolved b/Package.resolved index ceaf725..1171feb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "07a243cd8c2ed649f5fe5e76202ed091834801a0637c8b7785404e20b9e0cac1", "pins" : [ { "identity" : "combine-schedulers", @@ -95,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/m-housh/swift-shell-client.git", "state" : { - "revision" : "9c0c4757d0a2e313d1a4a28e60418a753d3e4230", - "version" : "0.1.4" + "revision" : "ea819f41b87aa94e792f028a031f5db786e79a94", + "version" : "0.2.1" } }, { @@ -118,5 +119,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index cebaf94..55cc5ca 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "swift-cli-version", platforms: [ - .macOS(.v10_15) + .macOS(.v13) ], products: [ .library(name: "CliVersion", targets: ["CliVersion"]), @@ -15,7 +15,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.2"), - .package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.3"), + .package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.0"), .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2") ], diff --git a/Sources/CliVersion/CliClient.swift b/Sources/CliVersion/CliClient.swift index 9223811..25331e2 100644 --- a/Sources/CliVersion/CliClient.swift +++ b/Sources/CliVersion/CliClient.swift @@ -16,16 +16,46 @@ public extension DependencyValues { /// Handles the command-line commands. @DependencyClient -public struct CliClient { +public struct CliClient: Sendable { /// Build and update the version based on the git tag, or branch + sha. - public var build: @Sendable (BuildOptions) throws -> String + public var build: @Sendable (SharedOptions) async throws -> String + + public var bump: @Sendable (BumpOption, SharedOptions) async throws -> String /// Generate a version file with an optional version that can be set manually. - public var generate: @Sendable (GenerateOptions) throws -> String + public var generate: @Sendable (SharedOptions) async throws -> String /// Update a version file manually. - public var update: @Sendable (UpdateOptions) throws -> String + public var update: @Sendable (SharedOptions) async throws -> String + + public enum BumpOption: Sendable, CaseIterable { + case major, minor, patch + } + + // TODO: Use Int for `verbose`. + public struct SharedOptions: Equatable, Sendable { + let gitDirectory: String? + let dryRun: Bool + let fileName: String + let target: String + let verbose: Bool + + public init( + gitDirectory: String? = nil, + dryRun: Bool = false, + fileName: String = "Version.swift", + target: String, + verbose: Bool = true + ) { + self.gitDirectory = gitDirectory + self.dryRun = dryRun + self.fileName = fileName + self.target = target + self.verbose = verbose + } + } + } extension CliClient: DependencyKey { @@ -33,9 +63,10 @@ extension CliClient: DependencyKey { public static func live(environment: [String: String]) -> Self { .init( - build: { try $0.run(environment) }, - generate: { try $0.run() }, - update: { try $0.run() } + build: { try $0.build(environment) }, + bump: { try $1.bump($0) }, + generate: { try $0.generate() }, + update: { try $0.update() } ) } @@ -44,82 +75,24 @@ extension CliClient: DependencyKey { } } -public extension CliClient { - - // TODO: Use Int for `verbose`. - struct SharedOptions: Sendable { - let dryRun: Bool - let fileName: String - let target: String - let verbose: Bool - - public init( - dryRun: Bool = false, - fileName: String, - target: String, - verbose: Bool = true - ) { - self.dryRun = dryRun - self.fileName = fileName - self.target = target - self.verbose = verbose - } - } - - struct BuildOptions: Sendable { - let gitDirectory: String? - let shared: SharedOptions - - public init( - gitDirectory: String? = nil, - shared: SharedOptions - ) { - self.gitDirectory = gitDirectory - self.shared = shared - } - } - - struct GenerateOptions: Sendable { - let shared: SharedOptions - - public init(shared: SharedOptions) { - self.shared = shared - } - } - - struct UpdateOptions: Sendable { - let gitDirectory: String? - let shared: SharedOptions - - public init( - gitDirectory: String? = nil, - shared: SharedOptions - ) { - self.gitDirectory = gitDirectory - self.shared = shared - } - } -} - // MARK: Private @_spi(Internal) public extension CliClient.SharedOptions { + var fileUrl: URL { - url(for: target).appendingPathComponent(fileName) - } + let target = self.target.hasPrefix(".") ? String(self.target.dropFirst()) : self.target + let targetHasSources = target.hasPrefix("Sources") || target.hasPrefix("/Sources") - func parseTarget() throws -> URL { - let targetUrl = fileUrl - .deletingLastPathComponent() - .deletingLastPathComponent() - - guard targetUrl.lastPathComponent == "Sources" else { - return url(for: "Sources") - .appendingPathComponent(target) - .appendingPathComponent(fileName) + var url = url(for: gitDirectory ?? (targetHasSources ? target : "Sources")) + if gitDirectory != nil { + if !targetHasSources { + url.appendPathComponent("Sources") + } + url.appendPathComponent(target) } - return fileUrl + url.appendPathComponent(fileName) + return url } @discardableResult @@ -134,10 +107,10 @@ public extension CliClient.SharedOptions { } } -private extension CliClient.BuildOptions { +private extension CliClient.SharedOptions { - func run(_ environment: [String: String]) throws -> String { - try shared.run { + func build(_ environment: [String: String]) throws -> String { + try run { @Dependency(\.gitVersionClient) var gitVersion @Dependency(\.fileClient) var fileClient @Dependency(\.logger) var logger @@ -150,28 +123,74 @@ private extension CliClient.BuildOptions { logger.debug("Building with git directory: \(gitDirectory)") - let fileUrl = shared.fileUrl + let fileUrl = self.fileUrl logger.debug("File url: \(fileUrl.cleanFilePath)") let currentVersion = try gitVersion.currentVersion(in: gitDirectory) - let fileContents = buildTemplate - .replacingOccurrences(of: "nil", with: "\"\(currentVersion)\"") + let fileContents = Template.build(currentVersion) try fileClient.write(string: fileContents, to: fileUrl) return fileUrl.cleanFilePath } } -} -private extension CliClient.GenerateOptions { - - func run() throws -> String { + func bump(_ type: CliClient.BumpOption) throws -> String { @Dependency(\.fileClient) var fileClient @Dependency(\.logger) var logger - let targetUrl = try shared.parseTarget() + let targetUrl = fileUrl + + 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:") } + + 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 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 + + let targetUrl = fileUrl logger.debug("Generate target url: \(targetUrl.cleanFilePath)") @@ -179,37 +198,68 @@ private extension CliClient.GenerateOptions { throw CliClientError.fileExists(path: targetUrl.cleanFilePath) } - if !shared.dryRun { - try fileClient.write(string: optionalTemplate, to: targetUrl) + 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 { + @Dependency(\.gitVersionClient) var gitVersionClient + return try generate(gitVersionClient.currentVersion(in: gitDirectory)) + } } -private extension CliClient.UpdateOptions { +@_spi(Internal) +public extension CliClient.BumpOption { - func run() throws -> String { - @Dependency(\.fileClient) var fileClient - @Dependency(\.gitVersionClient) var gitVersionClient - @Dependency(\.logger) var logger - - let targetUrl = try shared.parseTarget() - logger.debug("Target url: \(targetUrl.cleanFilePath)") - - let currentVersion = try gitVersionClient.currentVersion(in: gitDirectory) - - let fileContents = optionalTemplate - .replacingOccurrences(of: "nil", with: "\"\(currentVersion)\"") - - if !shared.dryRun { - try fileClient.write(string: fileContents, to: targetUrl) - } else { - logger.debug("Skipping due to dry run being passed.") - logger.debug("Parsed version: \(currentVersion)") + func bump( + major: inout Int, + minor: inout Int, + patch: inout Int + ) { + switch self { + case .major: + major += 1 + minor = 0 + patch = 0 + case .minor: + minor += 1 + patch = 0 + case .patch: + patch += 1 } - return targetUrl.cleanFilePath + } +} + +@_spi(Internal) +public struct Template { + let type: TemplateType + let version: String? + + enum TemplateType: String { + case optionalString = "String?" + case string = "String" + } + + var value: String { + return """ + // Do not set this variable, it is set during the build process. + let VERSION: \(type.rawValue) = \(version ?? "nil") + + """ + } + + public static func build(_ version: String? = nil) -> String { + Self(type: .string, version: version).value + } + + public static func optional(_ version: String? = nil) -> String { + Self(type: .optionalString, version: version).value } } @@ -226,4 +276,5 @@ let VERSION: String = nil enum CliClientError: Error { case gitDirectoryNotFound case fileExists(path: String) + case failedToParseVersionFile } diff --git a/Sources/CliVersion/FileClient.swift b/Sources/CliVersion/FileClient.swift index 245dc01..cc8843a 100644 --- a/Sources/CliVersion/FileClient.swift +++ b/Sources/CliVersion/FileClient.swift @@ -33,7 +33,7 @@ public struct FileClient: Sendable { public var read: @Sendable (URL) throws -> String /// Write `Data` to a file `URL`. - public private(set) var write: @Sendable (Data, URL) throws -> Void + public var write: @Sendable (Data, URL) throws -> Void /// Read the contents of a file at the given path. /// diff --git a/Sources/CliVersion/GitVersionClient.swift b/Sources/CliVersion/GitVersionClient.swift index 65ff8ae..888bd74 100644 --- a/Sources/CliVersion/GitVersionClient.swift +++ b/Sources/CliVersion/GitVersionClient.swift @@ -119,7 +119,7 @@ private struct GitVersion { shell: .env, environment: nil, in: workingDirectory ?? FileManager.default.currentDirectoryPath, - argument.arguments + argument.arguments.map(\.rawValue) ) } } diff --git a/Tests/CliVersionTests/CliClientTests.swift b/Tests/CliVersionTests/CliClientTests.swift new file mode 100644 index 0000000..6d0f717 --- /dev/null +++ b/Tests/CliVersionTests/CliClientTests.swift @@ -0,0 +1,106 @@ +@_spi(Internal) import CliVersion +import Dependencies +import Foundation +import Testing +import TestSupport + +@Suite("CliClientTests") +struct CliClientTests { + + @Test( + arguments: TestTarget.testCases + ) + func testBuild(target: String) async throws { + try await run { + let client = CliClient.liveValue + + let output = try await client.build(.init( + gitDirectory: "/baz", + dryRun: false, + fileName: "foo", + target: target, + verbose: true + )) + + #expect(output == "/baz/Sources/bar/foo") + } + } + + @Test( + arguments: CliClient.BumpOption.allCases + ) + func bump(type: CliClient.BumpOption) async throws { + try await run { + $0.fileClient.read = { _ in Template.optional("1.0.0") } + } operation: { + let client = CliClient.liveValue + let output = try await client.bump( + type, + .init( + gitDirectory: "/baz", + dryRun: false, + fileName: "foo", + target: "bar", + verbose: true + ) + ) + + #expect(output == "/baz/Sources/bar/foo") + } + } + + @Test( + arguments: TestTarget.testCases + ) + func generate(target: String) async throws { + // let (stream, continuation) = AsyncStream.makeStream() + try await run { + let client = CliClient.liveValue + let output = try await client.generate(.init( + gitDirectory: "/baz", + dryRun: false, + fileName: "foo", + target: target, + verbose: true + )) + #expect(output == "/baz/Sources/bar/foo") + } + } + + @Test( + arguments: TestTarget.testCases + ) + func update(target: String) async throws { + // let (stream, continuation) = AsyncStream.makeStream() + try await run { + let client = CliClient.liveValue + let output = try await client.update(.init( + gitDirectory: "/baz", + dryRun: false, + fileName: "foo", + target: target, + verbose: true + )) + #expect(output == "/baz/Sources/bar/foo") + } + } + + func run( + setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, + operation: @Sendable @escaping () async throws -> Void + ) async throws { + try await withDependencies { + $0.logger.logLevel = .debug + $0.fileClient = .noop + $0.fileClient.fileExists = { _ in false } + $0.gitVersionClient = .init { _ in "1.0.0" } + setupDependencies(&$0) + } operation: { + try await operation() + } + } +} + +enum TestTarget { + static let testCases = ["bar", "Sources/bar", "/Sources/bar", "./Sources/bar"] +}