From d7163480883a8b868afb02b20cb5cdecc1777315 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sun, 22 Dec 2024 23:30:25 -0500 Subject: [PATCH] feat: Begin working on configuration client. --- Package.resolved | 11 +- Package.swift | 12 +- Sources/CliClient/CliClient.swift | 322 +++--------------- Sources/CliClient/CliClientError.swift | 7 + .../CliClient/{ => Internal}/Constants.swift | 0 .../Internal/FileClient+semVar.swift | 48 +++ .../CliClient/{ => Internal}/Helpers.swift | 0 Sources/CliClient/Internal/Internal.swift | 144 ++++++++ Sources/CliClient/{ => Internal}/SemVar.swift | 16 +- Sources/CliClient/Internal/Template.swift | 30 ++ .../VersionStrategy+currentVersion.swift | 103 ++++++ Sources/ConfigurationClient/Coders.swift | 38 +++ .../ConfigurationClient/Configuration.swift | 75 ++++ .../ConfigurationClient.swift | 72 ++++ .../ConfigurationFile.swift | 58 ++++ Sources/ConfigurationClient/Constants.swift | 7 + Sources/cli-version/BuildCommand.swift | 38 ++- Sources/cli-version/BumpCommand.swift | 41 ++- Sources/cli-version/CliVersionCommand.swift | 6 +- Sources/cli-version/GenerateCommand.swift | 37 +- Sources/cli-version/GlobalOptions.swift | 229 ++++++++++--- Sources/cli-version/UpdateCommand.swift | 29 -- Tests/CliVersionTests/CliClientTests.swift | 42 ++- Tests/CliVersionTests/CliVersionTests.swift | 2 +- 24 files changed, 958 insertions(+), 409 deletions(-) create mode 100644 Sources/CliClient/CliClientError.swift rename Sources/CliClient/{ => Internal}/Constants.swift (100%) create mode 100644 Sources/CliClient/Internal/FileClient+semVar.swift rename Sources/CliClient/{ => Internal}/Helpers.swift (100%) create mode 100644 Sources/CliClient/Internal/Internal.swift rename Sources/CliClient/{ => Internal}/SemVar.swift (85%) create mode 100644 Sources/CliClient/Internal/Template.swift create mode 100644 Sources/CliClient/Internal/VersionStrategy+currentVersion.swift create mode 100644 Sources/ConfigurationClient/Coders.swift create mode 100644 Sources/ConfigurationClient/Configuration.swift create mode 100644 Sources/ConfigurationClient/ConfigurationClient.swift create mode 100644 Sources/ConfigurationClient/ConfigurationFile.swift create mode 100644 Sources/ConfigurationClient/Constants.swift delete mode 100644 Sources/cli-version/UpdateCommand.swift diff --git a/Package.resolved b/Package.resolved index 1f66754..f676a1f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "867dabf4e0a92ed23018f0ef682d2d889f080ca5eaf99607665038dfb3a7532f", + "originHash" : "b7e59cc29214df682ee1aa756371133e94660f8aabd03c193d455ba057ed5d17", "pins" : [ { "identity" : "combine-schedulers", @@ -109,6 +109,15 @@ "version" : "600.0.1" } }, + { + "identity" : "tomlkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LebJe/TOMLKit.git", + "state" : { + "revision" : "ec6198d37d495efc6acd4dffbd262cdca7ff9b3f", + "version" : "0.6.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 4e14859..9e68893 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,8 @@ let package = Package( .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.0.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2") + .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), + .package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0") ], targets: [ .executableTarget( @@ -40,6 +41,15 @@ let package = Package( name: "CliVersionTests", dependencies: ["CliClient", "TestSupport"] ), + .target( + name: "ConfigurationClient", + dependencies: [ + "FileClient", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "TOMLKit", package: "TOMLKit") + ] + ), .target( name: "FileClient", dependencies: [ diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index e715f52..898a486 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -21,39 +21,73 @@ public struct CliClient: Sendable { public var build: @Sendable (SharedOptions) async throws -> String /// Bump the existing version. - public var bump: @Sendable (BumpOption, 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 (SharedOptions) async throws -> String - /// Update a version file manually. - public var update: @Sendable (SharedOptions) async throws -> String - public enum BumpOption: Sendable, CaseIterable { - case major, minor, patch + case major, minor, patch, preRelease + } + + public enum PreReleaseStrategy: Equatable, Sendable { + /// Use output of tag, with branch and commit sha. + case branchAndCommit + + /// Provide a custom pre-release tag. + indirect case custom(String, PreReleaseStrategy? = nil) + + /// Use the output of `git describe --tags` + case tag + } + + public enum VersionStrategy: Equatable, Sendable { + case branchAndCommit + case semVar(SemVarOptions) + + public struct SemVarOptions: Equatable, Sendable { + let preReleaseStrategy: PreReleaseStrategy? + let requireExistingFile: Bool + let requireExistingSemVar: Bool + + public init( + preReleaseStrategy: PreReleaseStrategy? = nil, + requireExistingFile: Bool = true, + requireExistingSemVar: Bool = true + ) { + self.preReleaseStrategy = preReleaseStrategy + self.requireExistingFile = requireExistingFile + self.requireExistingSemVar = requireExistingSemVar + } + } } public struct SharedOptions: Equatable, Sendable { - let allowPreReleaseTag: Bool let dryRun: Bool let gitDirectory: String? let logLevel: Logger.Level + let preReleaseStrategy: PreReleaseStrategy? let target: String + let versionStrategy: VersionStrategy public init( - allowPreReleaseTag: Bool = false, - gitDirectory: String? = nil, dryRun: Bool = false, + gitDirectory: String? = nil, + logLevel: Logger.Level = .debug, + preReleaseStrategy: PreReleaseStrategy? = nil, target: String, - logLevel: Logger.Level = .debug + versionStrategy: VersionStrategy = .semVar(.init()) ) { - self.allowPreReleaseTag = allowPreReleaseTag - self.gitDirectory = gitDirectory self.dryRun = dryRun self.target = target + self.gitDirectory = gitDirectory self.logLevel = logLevel + self.preReleaseStrategy = preReleaseStrategy + self.versionStrategy = versionStrategy } + + var allowPreReleaseTag: Bool { preReleaseStrategy != nil } } } @@ -65,8 +99,7 @@ extension CliClient: DependencyKey { .init( build: { try await $0.build(environment) }, bump: { try await $1.bump($0) }, - generate: { try await $0.generate() }, - update: { try await $0.update() } + generate: { try await $0.generate() } ) } @@ -74,266 +107,3 @@ extension CliClient: DependencyKey { .live(environment: ProcessInfo.processInfo.environment) } } - -// MARK: Private - -@_spi(Internal) -public extension CliClient.SharedOptions { - - func fileUrl() async throws -> URL { - @Dependency(\.fileClient) var fileClient - - let target = self.target.hasPrefix(".") ? String(self.target.dropFirst()) : self.target - let targetHasSources = target.hasPrefix("Sources") || target.hasPrefix("/Sources") - - var url = url(for: gitDirectory ?? (targetHasSources ? target : "Sources")) - - if gitDirectory != nil { - if !targetHasSources { - url.appendPathComponent("Sources") - } - url.appendPathComponent(target) - } - - let isDirectory = try await fileClient.isDirectory(url.cleanFilePath) - - if isDirectory { - url.appendPathComponent(Constants.defaultFileName) - } - - return url - } - - @discardableResult - func run( - _ operation: () async throws -> T - ) async rethrows -> T { - try await withDependencies { - $0.logger.logLevel = logLevel - } operation: { - try await operation() - } - } - - func write(_ string: String, to url: URL) async throws { - @Dependency(\.fileClient) var fileClient - @Dependency(\.logger) var logger - if !dryRun { - try await fileClient.write(string: string, to: url) - } else { - logger.debug("Skipping, due to dry-run being passed.") - logger.debug("\(string)") - } - } -} - -private extension CliClient.SharedOptions { - - func gitVersion() async throws -> GitClient.Version { - @Dependency(\.gitClient) var gitClient - - if let exactMatch = try? await gitClient.version(.init( - gitDirectory: gitDirectory, - style: .tag(exactMatch: true) - )) { - return exactMatch - } else if let partialMatch = try? await gitClient.version(.init( - gitDirectory: gitDirectory, - style: .tag(exactMatch: false) - )) { - return partialMatch - } else { - return try await gitClient.version(.init( - gitDirectory: gitDirectory, - style: .branch(commitSha: true) - )) - } - } - - func gitSemVar() async throws -> SemVar { - @Dependency(\.gitClient) var gitClient - - let version = try await gitVersion() - - guard let semVar = version.semVar else { - return .init(preRelease: version.description) - } - - if allowPreReleaseTag, semVar.preRelease == nil { - let branchVersion = try await gitClient.version(.init( - gitDirectory: gitDirectory, - style: .branch(commitSha: true) - )) - return .init( - major: semVar.major, - minor: semVar.minor, - patch: semVar.patch, - preRelease: branchVersion.description - ) - } - return semVar - } - - func build(_ environment: [String: String]) async throws -> String { - try await run { - @Dependency(\.gitClient) var gitVersion - @Dependency(\.fileClient) var fileClient - @Dependency(\.logger) var logger - - let fileUrl = try await self.fileUrl() - logger.debug("File url: \(fileUrl.cleanFilePath)") - - let currentVersion = try await gitVersion.currentVersion(in: gitDirectory) - logger.debug("Git version: \(currentVersion)") - - let fileContents = Template.build(currentVersion) - - try await write(fileContents, to: fileUrl) - - return fileUrl.cleanFilePath - } - } - - private func getVersionString() async throws -> (version: String, usesOptionalType: Bool) { - @Dependency(\.fileClient) var fileClient - @Dependency(\.gitClient) var gitVersionClient - @Dependency(\.logger) var logger - - let targetUrl = try await fileUrl() - - guard fileClient.fileExists(targetUrl) else { - // Get the latest tag, not requiring an exact tag set on the commit. - // This will return a tag, that may have some more data on the patch - // portion of the tag, such as: 0.1.1-4-g59bc977 - let version = try await gitVersionClient.currentVersion(in: gitDirectory, exactMatch: false) - // TODO: Not sure what to do for the uses optional value here?? - return (version, false) - } - - let contents = try await fileClient.read(targetUrl) - let versionLine = contents.split(separator: "\n") - .first { $0.hasPrefix("let VERSION:") } - - guard let versionLine else { - throw CliClientError.failedToParseVersionFile - } - logger.debug("Version line: \(versionLine)") - - let isOptional = versionLine.contains("String?") - logger.debug("Uses optional: \(isOptional)") - - let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last - guard let versionString else { - throw CliClientError.failedToParseVersionFile - } - return (String(versionString), isOptional) - } - - private func getSemVar(_ version: String, _ bump: CliClient.BumpOption) throws -> SemVar { - let semVar = SemVar(string: version) ?? .init() - return semVar.bump(bump) - } - - func bump(_ type: CliClient.BumpOption) async throws -> String { - try await run { - @Dependency(\.fileClient) var fileClient - @Dependency(\.logger) var logger - - let targetUrl = try await fileUrl() - - logger.debug("Bump target url: \(targetUrl.cleanFilePath)") - - let (versionString, usesOptional) = try await getVersionString() - let semVar = try getSemVar(versionString, type) - let version = semVar.versionString(withPreReleaseTag: allowPreReleaseTag) - logger.debug("Bumped version: \(version)") - - let template = usesOptional ? Template.optional(version) : Template.build(version) - try await write(template, to: targetUrl) - return targetUrl.cleanFilePath - } - } - - func generate(_ version: String? = nil) async throws -> String { - try await run { - @Dependency(\.fileClient) var fileClient - @Dependency(\.logger) var logger - - let targetUrl = try await fileUrl() - - logger.debug("Generate target url: \(targetUrl.cleanFilePath)") - - guard !fileClient.fileExists(targetUrl) else { - throw CliClientError.fileExists(path: targetUrl.cleanFilePath) - } - - let template = Template.optional(version) - try await write(template, to: targetUrl) - return targetUrl.cleanFilePath - } - } - - func update() async throws -> String { - @Dependency(\.gitClient) var gitVersionClient - return try await generate(gitVersionClient.currentVersion(in: gitDirectory)) - } -} - -@_spi(Internal) -public extension CliClient.BumpOption { - - 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 - } - } -} - -@_spi(Internal) -public struct Template: Sendable { - let type: TemplateType - let version: String? - - enum TemplateType: String, Sendable { - case optionalString = "String?" - case string = "String" - } - - var value: String { - let versionString = version != nil ? "\"\(version!)\"" : "nil" - return """ - // Do not set this variable, it is set during the build process. - let VERSION: \(type.rawValue) = \(versionString) - """ - } - - public static func build(_ version: String? = nil) -> String { - nonOptional(version) - } - - public static func nonOptional(_ version: String? = nil) -> String { - Self(type: .string, version: version).value - } - - public static func optional(_ version: String? = nil) -> String { - Self(type: .optionalString, version: version).value - } -} - -enum CliClientError: Error { - case gitDirectoryNotFound - case fileExists(path: String) - case failedToParseVersionFile -} diff --git a/Sources/CliClient/CliClientError.swift b/Sources/CliClient/CliClientError.swift new file mode 100644 index 0000000..2f8e488 --- /dev/null +++ b/Sources/CliClient/CliClientError.swift @@ -0,0 +1,7 @@ +enum CliClientError: Error { + case gitDirectoryNotFound + case fileExists(path: String) + case fileDoesNotExist(path: String) + case failedToParseVersionFile + case semVarNotFound +} diff --git a/Sources/CliClient/Constants.swift b/Sources/CliClient/Internal/Constants.swift similarity index 100% rename from Sources/CliClient/Constants.swift rename to Sources/CliClient/Internal/Constants.swift diff --git a/Sources/CliClient/Internal/FileClient+semVar.swift b/Sources/CliClient/Internal/FileClient+semVar.swift new file mode 100644 index 0000000..5664317 --- /dev/null +++ b/Sources/CliClient/Internal/FileClient+semVar.swift @@ -0,0 +1,48 @@ +import Dependencies +import FileClient +import Foundation +import GitClient + +@_spi(Internal) +public extension FileClient { + private func getVersionString( + fileUrl: URL, + gitDirectory: String? + ) async throws -> (version: String, usesOptionalType: Bool) { + @Dependency(\.gitClient) var gitClient + @Dependency(\.logger) var logger + + let targetUrl = fileUrl + + guard fileExists(targetUrl) else { + throw CliClientError.fileDoesNotExist(path: fileUrl.cleanFilePath) + } + + let contents = try await read(targetUrl) + let versionLine = contents.split(separator: "\n") + .first { $0.hasPrefix("let VERSION:") } + + guard let versionLine else { + throw CliClientError.failedToParseVersionFile + } + logger.debug("Version line: \(versionLine)") + + let isOptional = versionLine.contains("String?") + logger.debug("Uses optional: \(isOptional)") + + let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last + guard let versionString else { + throw CliClientError.failedToParseVersionFile + } + return (String(versionString), isOptional) + } + + func semVar( + file: URL, + gitDirectory: String? + ) async throws -> (semVar: SemVar?, usesOptionalType: Bool) { + let (string, usesOptionalType) = try await getVersionString(fileUrl: file, gitDirectory: gitDirectory) + return (SemVar(string: string), usesOptionalType) + } + +} diff --git a/Sources/CliClient/Helpers.swift b/Sources/CliClient/Internal/Helpers.swift similarity index 100% rename from Sources/CliClient/Helpers.swift rename to Sources/CliClient/Internal/Helpers.swift diff --git a/Sources/CliClient/Internal/Internal.swift b/Sources/CliClient/Internal/Internal.swift new file mode 100644 index 0000000..d135ab2 --- /dev/null +++ b/Sources/CliClient/Internal/Internal.swift @@ -0,0 +1,144 @@ +import Dependencies +import FileClient +import Foundation +import GitClient + +@_spi(Internal) +public extension CliClient.SharedOptions { + + func parseTargetUrl() async throws -> URL { + @Dependency(\.fileClient) var fileClient + + let target = target.hasPrefix(".") ? String(target.dropFirst()) : target + let targetHasSources = target.hasPrefix("Sources") || target.hasPrefix("/Sources") + + var url = url(for: gitDirectory ?? (targetHasSources ? target : "Sources")) + + if gitDirectory != nil { + if !targetHasSources { + url.appendPathComponent("Sources") + } + url.appendPathComponent(target) + } + + let isDirectory = try await fileClient.isDirectory(url.cleanFilePath) + + if isDirectory { + url.appendPathComponent(Constants.defaultFileName) + } + + return url + } + + @discardableResult + func run( + _ operation: (CurrentVersionContainer) async throws -> Void + ) async rethrows -> String { + try await withDependencies { + $0.logger.logLevel = logLevel + } operation: { + @Dependency(\.logger) var logger + + let targetUrl = try await parseTargetUrl() + logger.debug("Target: \(targetUrl.cleanFilePath)") + + try await operation( + versionStrategy.currentVersion( + file: targetUrl, + gitDirectory: gitDirectory + ) + ) + + return targetUrl.cleanFilePath + } + } + + func write(_ string: String, to url: URL) async throws { + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + if !dryRun { + try await fileClient.write(string: string, to: url) + } else { + logger.debug("Skipping, due to dry-run being passed.") + logger.debug("\(string)") + } + } + + func write(_ currentVersion: CurrentVersionContainer) async throws { + @Dependency(\.logger) var logger + + let version = try currentVersion.version.string(allowPreReleaseTag: allowPreReleaseTag) + logger.debug("Version: \(version)") + + let template = currentVersion.usesOptionalType ? Template.optional(version) : Template.nonOptional(version) + logger.trace("Template string: \(template)") + + try await write(template, to: currentVersion.targetUrl) + } +} + +@_spi(Internal) +public struct CurrentVersionContainer: Sendable { + + let targetUrl: URL + let version: Version + + var usesOptionalType: Bool { + switch version { + case .string: return false + case let .semVar(_, usesOptionalType): return usesOptionalType + } + } + + public enum Version: Sendable { + case string(String) + case semVar(SemVar, usesOptionalType: Bool = true) + + func string(allowPreReleaseTag: Bool) throws -> String { + switch self { + case let .string(string): + return string + case let .semVar(semVar, usesOptionalType: _): + return semVar.versionString(withPreReleaseTag: allowPreReleaseTag) + } + } + } +} + +extension CliClient.SharedOptions { + + func build(_ environment: [String: String]) async throws -> String { + try await run { currentVersion in + try await write(currentVersion) + } + } + + func bump(_ type: CliClient.BumpOption?) async throws -> String { + guard let type else { + return try await generate() + } + + return try await run { container in + + @Dependency(\.logger) var logger + + switch container.version { + case .string: // When we did not parse a semVar, just write whatever we parsed for the current version. + try await write(container) + + case let .semVar(semVar, usesOptionalType: usesOptionalType): + let bumped = semVar.bump(type, preRelease: nil) // preRelease is already set on semVar. + let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag) + logger.debug("Bumped version: \(version)") + let template = usesOptionalType ? Template.optional(version) : Template.build(version) + try await write(template, to: container.targetUrl) + } + } + } + + func generate() async throws -> String { + try await run { currentVersion in + try await write(currentVersion) + } + } +} diff --git a/Sources/CliClient/SemVar.swift b/Sources/CliClient/Internal/SemVar.swift similarity index 85% rename from Sources/CliClient/SemVar.swift rename to Sources/CliClient/Internal/SemVar.swift index d5b35e2..ea78875 100644 --- a/Sources/CliClient/SemVar.swift +++ b/Sources/CliClient/Internal/SemVar.swift @@ -1,3 +1,5 @@ +import Dependencies +import FileClient import Foundation import GitClient @@ -74,7 +76,7 @@ public struct SemVar: CustomStringConvertible, Equatable, Sendable { } // Bumps the sem-var by the given option (major, minor, patch) - public func bump(_ option: CliClient.BumpOption) -> Self { + public func bump(_ option: CliClient.BumpOption, preRelease: String?) -> Self { switch option { case .major: return .init( @@ -97,8 +99,20 @@ public struct SemVar: CustomStringConvertible, Equatable, Sendable { patch: patch + 1, preRelease: preRelease ) + case .preRelease: + guard let preRelease else { return self } + return applyingPreRelease(preRelease) } } + + public func applyingPreRelease(_ preRelease: String) -> Self { + .init( + major: major, + minor: minor, + patch: patch, + preRelease: preRelease + ) + } } @_spi(Internal) diff --git a/Sources/CliClient/Internal/Template.swift b/Sources/CliClient/Internal/Template.swift new file mode 100644 index 0000000..5a3c89c --- /dev/null +++ b/Sources/CliClient/Internal/Template.swift @@ -0,0 +1,30 @@ +@_spi(Internal) +public struct Template: Sendable { + let type: TemplateType + let version: String? + + enum TemplateType: String, Sendable { + case optionalString = "String?" + case string = "String" + } + + var value: String { + let versionString = version != nil ? "\"\(version!)\"" : "nil" + return """ + // Do not set this variable, it is set during the build process. + let VERSION: \(type.rawValue) = \(versionString) + """ + } + + public static func build(_ version: String? = nil) -> String { + nonOptional(version) + } + + public static func nonOptional(_ version: String? = nil) -> String { + Self(type: .string, version: version).value + } + + public static func optional(_ version: String? = nil) -> String { + Self(type: .optionalString, version: version).value + } +} diff --git a/Sources/CliClient/Internal/VersionStrategy+currentVersion.swift b/Sources/CliClient/Internal/VersionStrategy+currentVersion.swift new file mode 100644 index 0000000..c74bdd6 --- /dev/null +++ b/Sources/CliClient/Internal/VersionStrategy+currentVersion.swift @@ -0,0 +1,103 @@ +import Dependencies +import FileClient +import struct Foundation.URL +import GitClient + +@_spi(Internal) +public extension CliClient.PreReleaseStrategy { + + func preReleaseString(gitDirectory: String?) async throws -> String { + @Dependency(\.gitClient) var gitClient + switch self { + case let .custom(string, child): + guard let child else { return string } + return try await "\(string)-\(child.preReleaseString(gitDirectory: gitDirectory))" + case .tag: + return try await gitClient.version(.init( + gitDirectory: gitDirectory, + style: .tag(exactMatch: false) + )).description + case .branchAndCommit: + return try await gitClient.version(.init( + gitDirectory: gitDirectory, + style: .branch(commitSha: true) + )).description + } + } +} + +@_spi(Internal) +public extension CliClient.VersionStrategy.SemVarOptions { + + private func applyingPreRelease(_ semVar: SemVar, _ gitDirectory: String?) async throws -> SemVar { + guard let preReleaseStrategy else { return semVar } + let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory) + return semVar.applyingPreRelease(preRelease) + } + + func currentVersion(file: URL, gitDirectory: String? = nil) async throws -> CurrentVersionContainer.Version { + @Dependency(\.fileClient) var fileClient + @Dependency(\.gitClient) var gitClient + + let fileOutput = try? await fileClient.semVar(file: file, gitDirectory: gitDirectory) + var semVar = fileOutput?.semVar + let usesOptionalType = fileOutput?.usesOptionalType + + if requireExistingFile { + guard let semVar else { + throw CliClientError.fileDoesNotExist(path: file.cleanFilePath) + } + return try await .semVar( + applyingPreRelease(semVar, gitDirectory), + usesOptionalType: usesOptionalType ?? false + ) + } + + // Didn't have existing semVar loaded from file, so check for git-tag. + + semVar = try await gitClient.version(.init( + gitDirectory: gitDirectory, + style: .tag(exactMatch: false) + )).semVar + if requireExistingSemVar { + guard let semVar else { + fatalError() + } + return try await .semVar( + applyingPreRelease(semVar, gitDirectory), + usesOptionalType: usesOptionalType ?? false + ) + } + + return try await .semVar( + applyingPreRelease(.init(), gitDirectory), + usesOptionalType: usesOptionalType ?? false + ) + } +} + +@_spi(Internal) +public extension CliClient.VersionStrategy { + + func currentVersion(file: URL, gitDirectory: String?) async throws -> CurrentVersionContainer { + @Dependency(\.gitClient) var gitClient + + switch self { + case .branchAndCommit: + return try await .init( + targetUrl: file, + version: .string( + gitClient.version(.init( + gitDirectory: gitDirectory, + style: .branch(commitSha: true) + )).description + ) + ) + case let .semVar(options): + return try await .init( + targetUrl: file, + version: options.currentVersion(file: file, gitDirectory: gitDirectory) + ) + } + } +} diff --git a/Sources/ConfigurationClient/Coders.swift b/Sources/ConfigurationClient/Coders.swift new file mode 100644 index 0000000..52f9ba6 --- /dev/null +++ b/Sources/ConfigurationClient/Coders.swift @@ -0,0 +1,38 @@ +import Dependencies +import DependenciesMacros +import Foundation +import TOMLKit + +public extension DependencyValues { + var coders: Coders { + get { self[Coders.self] } + set { self[Coders.self] = newValue } + } +} + +@DependencyClient +public struct Coders: Sendable { + public var jsonDecoder: @Sendable () -> JSONDecoder = { .init() } + public var jsonEncoder: @Sendable () -> JSONEncoder = { .init() } + public var tomlDecoder: @Sendable () -> TOMLDecoder = { .init() } + public var tomlEncoder: @Sendable () -> TOMLEncoder = { .init() } +} + +extension Coders: DependencyKey { + public static var testValue: Coders { + .init( + jsonDecoder: { .init() }, + jsonEncoder: { defaultJsonEncoder }, + tomlDecoder: { .init() }, + tomlEncoder: { .init() } + ) + } + + public static var liveValue: Coders { .testValue } + + private static let defaultJsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() +} diff --git a/Sources/ConfigurationClient/Configuration.swift b/Sources/ConfigurationClient/Configuration.swift new file mode 100644 index 0000000..74a042a --- /dev/null +++ b/Sources/ConfigurationClient/Configuration.swift @@ -0,0 +1,75 @@ +import Foundation + +public struct Configuration: Codable, Sendable { + public let target: Target? + public let strategy: VersionStrategy? + + public init( + target: Target? = nil, + strategy: VersionStrategy? = .semvar() + ) { + self.target = target + self.strategy = strategy + } +} + +public extension Configuration { + + enum VersionStrategy: Codable, Equatable, Sendable { + case branch(Branch = .init()) + case semvar(SemVar = .init()) + + public struct Branch: Codable, Equatable, Sendable { + let includeCommitSha: Bool + + public init(includeCommitSha: Bool = true) { + self.includeCommitSha = includeCommitSha + } + } + + public enum PreReleaseStrategy: Codable, Equatable, Sendable { + /// Use output of tag, with branch and commit sha. + case branch(Branch = .init()) + + /// Provide a custom pre-release tag. + indirect case custom(String, PreReleaseStrategy? = nil) + + /// Use the output of `git describe --tags` + case gitTag + } + + public struct SemVar: Codable, Equatable, Sendable { + let preReleaseStrategy: PreReleaseStrategy? + let requireExistingFile: Bool + let requireExistingSemVar: Bool + + public init( + preReleaseStrategy: PreReleaseStrategy? = nil, + requireExistingFile: Bool = true, + requireExistingSemVar: Bool = true + ) { + self.preReleaseStrategy = preReleaseStrategy + self.requireExistingFile = requireExistingFile + self.requireExistingSemVar = requireExistingSemVar + } + } + } + + enum Target: Codable, Equatable, Sendable { + case path(String) + case module(Module) + + public struct Module: Codable, Equatable, Sendable { + public let name: String + public let fileName: String + + public init( + _ name: String, + fileName: String = "Version.swift" + ) { + self.name = name + self.fileName = fileName + } + } + } +} diff --git a/Sources/ConfigurationClient/ConfigurationClient.swift b/Sources/ConfigurationClient/ConfigurationClient.swift new file mode 100644 index 0000000..ac6be15 --- /dev/null +++ b/Sources/ConfigurationClient/ConfigurationClient.swift @@ -0,0 +1,72 @@ +import Dependencies +import DependenciesMacros +import FileClient +import Foundation + +public extension DependencyValues { + var configurationClient: ConfigurationClient { + get { self[ConfigurationClient.self] } + set { self[ConfigurationClient.self] = newValue } + } +} + +@DependencyClient +public struct ConfigurationClient: Sendable { + public var find: @Sendable (URL?) async throws -> ConfigruationFile? + public var load: @Sendable (ConfigruationFile) async throws -> Configuration + public var write: @Sendable (Configuration, ConfigruationFile) async throws -> Void + + public func findAndLoad(_ url: URL? = nil) async throws -> Configuration { + guard let url = try await find(url) else { + throw ConfigurationClientError.configurationNotFound + } + return try await load(url) + } +} + +extension ConfigurationClient: DependencyKey { + public static let testValue: ConfigurationClient = Self() + + public static var liveValue: ConfigurationClient { + .init( + find: { try await findConfiguration($0) }, + load: { try await $0.load() ?? .init() }, + write: { try await $1.write($0) } + ) + } +} + +private func findConfiguration(_ url: URL?) async throws -> ConfigruationFile? { + @Dependency(\.fileClient) var fileClient + + var url: URL! = url + if url == nil { + url = try await URL(filePath: fileClient.currentDirectory()) + } + + // Check if url is a valid configuration url. + var configurationFile = ConfigruationFile(url: url) + if let configurationFile { return configurationFile } + + guard try await fileClient.isDirectory(url.cleanFilePath) else { + throw ConfigurationClientError.invalidConfigurationDirectory(path: url.cleanFilePath) + } + + // Check for toml file. + let tomlUrl = url.appending(path: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).toml") + configurationFile = ConfigruationFile(url: tomlUrl) + if let configurationFile { return configurationFile } + + // Check for json file. + let jsonUrl = url.appending(path: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).json") + configurationFile = ConfigruationFile(url: jsonUrl) + if let configurationFile { return configurationFile } + + // Couldn't find valid configuration file. + return nil +} + +enum ConfigurationClientError: Error { + case configurationNotFound + case invalidConfigurationDirectory(path: String) +} diff --git a/Sources/ConfigurationClient/ConfigurationFile.swift b/Sources/ConfigurationClient/ConfigurationFile.swift new file mode 100644 index 0000000..568260f --- /dev/null +++ b/Sources/ConfigurationClient/ConfigurationFile.swift @@ -0,0 +1,58 @@ +import Dependencies +import FileClient +import Foundation + +public enum ConfigruationFile: Equatable, Sendable { + case json(URL) + case toml(URL) + + public init?(url: URL) { + if url.pathExtension == "toml" { + self = .toml(url) + } else if url.pathExtension == "json" { + self = .json(url) + } else { + return nil + } + } + + var url: URL { + switch self { + case let .json(url): return url + case let .toml(url): return url + } + } +} + +extension ConfigruationFile { + + func load() async throws -> Configuration? { + @Dependency(\.coders) var coders + @Dependency(\.fileClient) var fileClient + + switch self { + case .json: + let data = try await Data(fileClient.read(url.cleanFilePath).utf8) + return try? coders.jsonDecoder().decode(Configuration.self, from: data) + case .toml: + let string = try await fileClient.read(url.cleanFilePath) + return try? coders.tomlDecoder().decode(Configuration.self, from: string) + } + } + + func write(_ configuration: Configuration) async throws { + @Dependency(\.coders) var coders + @Dependency(\.fileClient) var fileClient + + let data: Data + + switch self { + case .json: + data = try coders.jsonEncoder().encode(configuration) + case .toml: + data = try Data(coders.tomlEncoder().encode(configuration).utf8) + } + + try await fileClient.write(data, url) + } +} diff --git a/Sources/ConfigurationClient/Constants.swift b/Sources/ConfigurationClient/Constants.swift new file mode 100644 index 0000000..312463d --- /dev/null +++ b/Sources/ConfigurationClient/Constants.swift @@ -0,0 +1,7 @@ +import Foundation + +extension ConfigurationClient { + enum Constants { + static let defaultFileNameWithoutExtension = ".bump-version" + } +} diff --git a/Sources/cli-version/BuildCommand.swift b/Sources/cli-version/BuildCommand.swift index 0f277c6..e623468 100644 --- a/Sources/cli-version/BuildCommand.swift +++ b/Sources/cli-version/BuildCommand.swift @@ -7,17 +7,41 @@ extension CliVersionCommand { struct Build: AsyncParsableCommand { static let 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." + discussion: "This should generally not be interacted with directly, outside of the build plugin.", + subcommands: [BranchStyle.self, SemVarStyle.self], + defaultSubcommand: SemVarStyle.self + ) + } +} + +extension CliVersionCommand.Build { + struct BranchStyle: AsyncParsableCommand { + + static let configuration: CommandConfiguration = .init( + commandName: "branch", + abstract: "Build using branch and commit sha as the version.", + discussion: "This should generally not be interacted with directly, outside of the plugin usage context." ) - @OptionGroup var globals: GlobalOptions + @OptionGroup var globals: GlobalBranchOptions func run() async throws { - try await globals.run { - @Dependency(\.cliClient) var cliClient - let output = try await cliClient.build(globals.shared) - print(output) - } + try await globals.shared().run(\.build) + } + } + + struct SemVarStyle: AsyncParsableCommand { + + static let configuration: CommandConfiguration = .init( + commandName: "semvar", + abstract: "Generates a version file with SemVar style.", + discussion: "This should generally not be interacted with directly, outside of the plugin usage context." + ) + + @OptionGroup var globals: GlobalSemVarOptions + + func run() async throws { + try await globals.shared().run(\.build) } } } diff --git a/Sources/cli-version/BumpCommand.swift b/Sources/cli-version/BumpCommand.swift index 1f2e6f8..0dab55f 100644 --- a/Sources/cli-version/BumpCommand.swift +++ b/Sources/cli-version/BumpCommand.swift @@ -7,21 +7,48 @@ extension CliVersionCommand { static let configuration = CommandConfiguration( commandName: "bump", - abstract: "Bump version of a command-line tool." + abstract: "Bump version of a command-line tool.", + subcommands: [ + SemVarStyle.self, + BranchStyle.self + ], + defaultSubcommand: SemVarStyle.self + ) + } +} + +extension CliVersionCommand.Bump { + + struct BranchStyle: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "branch", + abstract: "Bump using the current branch and commit sha." ) - @OptionGroup var globals: GlobalOptions + @OptionGroup var globals: GlobalBranchOptions + + func run() async throws { + try await globals.shared().run(\.bump, args: nil) + } + } + + struct SemVarStyle: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "semvar", + abstract: "Bump using semvar style options." + ) + + @OptionGroup var globals: GlobalSemVarOptions @Flag var bumpOption: CliClient.BumpOption = .patch func run() async throws { - try await globals.run { - @Dependency(\.cliClient) var cliClient - let output = try await cliClient.bump(bumpOption, globals.shared) - print(output) - } + try await globals.shared().run(\.bump, args: bumpOption) } + } } diff --git a/Sources/cli-version/CliVersionCommand.swift b/Sources/cli-version/CliVersionCommand.swift index cda11b2..67723be 100644 --- a/Sources/cli-version/CliVersionCommand.swift +++ b/Sources/cli-version/CliVersionCommand.swift @@ -9,8 +9,8 @@ struct CliVersionCommand: AsyncParsableCommand { subcommands: [ Build.self, Bump.self, - Generate.self, - Update.self - ] + Generate.self + ], + defaultSubcommand: Bump.self ) } diff --git a/Sources/cli-version/GenerateCommand.swift b/Sources/cli-version/GenerateCommand.swift index 4a105ae..197d7ca 100644 --- a/Sources/cli-version/GenerateCommand.swift +++ b/Sources/cli-version/GenerateCommand.swift @@ -9,17 +9,40 @@ extension CliVersionCommand { static let 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.", - version: VERSION ?? "0.0.0" + subcommands: [BranchStyle.self, SemVarStyle.self], + defaultSubcommand: SemVarStyle.self + ) + } +} + +extension CliVersionCommand.Generate { + struct BranchStyle: AsyncParsableCommand { + + static let configuration: CommandConfiguration = .init( + commandName: "branch", + abstract: "Generates a version file with branch and commit sha as the version.", + discussion: "This command can be interacted with directly, outside of the plugin usage context." ) - @OptionGroup var globals: GlobalOptions + @OptionGroup var globals: GlobalBranchOptions func run() async throws { - try await globals.run { - @Dependency(\.cliClient) var cliClient - let output = try await cliClient.generate(globals.shared) - print(output) - } + try await globals.shared().run(\.generate) + } + } + + struct SemVarStyle: AsyncParsableCommand { + + static let configuration: CommandConfiguration = .init( + commandName: "semvar", + abstract: "Generates a version file with SemVar style.", + discussion: "This command can be interacted with directly, outside of the plugin usage context." + ) + + @OptionGroup var globals: GlobalSemVarOptions + + func run() async throws { + try await globals.shared().run(\.generate) } } } diff --git a/Sources/cli-version/GlobalOptions.swift b/Sources/cli-version/GlobalOptions.swift index 9bd5b55..86c7172 100644 --- a/Sources/cli-version/GlobalOptions.swift +++ b/Sources/cli-version/GlobalOptions.swift @@ -2,39 +2,12 @@ import ArgumentParser @_spi(Internal) import CliClient import Dependencies import Foundation +import Rainbow -func parseTarget(_ target: String) -> URL { - let url = URL(fileURLWithPath: target) - let urlTest = url - .deletingLastPathComponent() +struct GlobalOptions: ParsableArguments { - guard urlTest.lastPathComponent == "Sources" else { - return URL(fileURLWithPath: "Sources") - .appendingPathComponent(target) - } - return url -} - -extension URL { - func fileString() -> String { - absoluteString - .replacingOccurrences(of: "file://", with: "") - } -} - -let optionalTemplate = """ -// Do not set this variable, it is set during the build process. -let VERSION: String? = nil - -""" - -let buildTemplate = """ -// Do not set this variable, it is set during the build process. -let VERSION: String = nil - -""" - -struct GlobalOptions: ParsableArguments { + @OptionGroup var targetOptions: TargetOptions + @OptionGroup var child: Child @Option( name: .customLong("git-directory"), @@ -42,19 +15,10 @@ struct GlobalOptions: ParsableArguments { ) var gitDirectory: String? - @Option( - name: .shortAndLong, - help: "The target for the version file." + @Flag( + name: .customLong("dry-run"), + help: "Print's what would be written to a target version file." ) - var target: String - - @Option( - name: .customLong("filename"), - help: "Specify the file name for the version file in the target." - ) - var fileName: String = "Version.swift" - - @Flag(name: .customLong("dry-run")) var dryRun: Bool = false @Flag( @@ -62,26 +26,185 @@ struct GlobalOptions: ParsableArguments { help: "Increase logging level, can be passed multiple times (example: -vvv)." ) var verbose: Int + } -extension GlobalOptions { +struct Empty: ParsableArguments {} - var shared: CliClient.SharedOptions { - .init( - gitDirectory: gitDirectory, - dryRun: dryRun, - target: target, - logLevel: .init(verbose: verbose) - ) +struct TargetOptions: ParsableArguments { + @Option( + name: .shortAndLong, + help: "Path to the version file, not required if module is set." + ) + var path: String? + + @Option( + name: .shortAndLong, + help: "The target module name or directory path, not required if path is set." + ) + var module: String? + + @Option( + name: [.customShort("n"), .long], + help: "The file name inside the target module, required if module is set." + ) + var fileName: String = "Version.swift" + +} + +struct PreReleaseOptions: ParsableArguments { + @Flag( + name: [.customShort("s"), .customLong("pre-release-branch-style")], + help: """ + Use branch name and commit sha for pre-release suffix, ignored if branch is set. + """ + ) + var useBranchAsPreRelease: Bool = false + + @Flag( + name: [.customShort("g"), .customLong("pre-release-git-tag-style")], + help: """ + Use `git describe --tags` for pre-release suffix, ignored if branch is set. + """ + ) + var useTagAsPreRelease: Bool = false + + @Option( + name: .shortAndLong, + help: """ + Apply custom pre-release suffix, can also use branch or tag along with this + option as a prefix, used if branch is not set. (example: \"rc\") + """ + ) + var custom: String? +} + +struct SemVarOptions: ParsableArguments { + + @Flag( + name: .long, + help: """ + Fail if an existing version file does not exist, \("ignored if:".yellow.bold) \("branch is set".italic). + """ + ) + var requireExistingFile: Bool = false + + @Flag( + name: .long, + help: "Fail if a sem-var is not parsed from existing file or git tag, used if branch is not set." + ) + var requireExistingSemvar: Bool = false + + @OptionGroup var preRelease: PreReleaseOptions +} + +typealias GlobalSemVarOptions = GlobalOptions +typealias GlobalBranchOptions = GlobalOptions + +extension GlobalSemVarOptions { + func shared() throws -> CliClient.SharedOptions { + try shared(.semVar(child.semVarOptions())) } +} - func run(_ operation: () async throws -> Void) async throws { +extension GlobalBranchOptions { + func shared() throws -> CliClient.SharedOptions { + try shared(.branchAndCommit) + } +} + +extension CliClient.SharedOptions { + + func run(_ keyPath: KeyPath String>) async throws { try await withDependencies { $0.fileClient = .liveValue $0.gitClient = .liveValue $0.cliClient = .liveValue } operation: { - try await operation() + @Dependency(\.cliClient) var cliClient + let output = try await cliClient[keyPath: keyPath](self) + print(output) + } + } + + func run( + _ keyPath: KeyPath String>, + args: T + ) async throws { + try await withDependencies { + $0.fileClient = .liveValue + $0.gitClient = .liveValue + $0.cliClient = .liveValue + } operation: { + @Dependency(\.cliClient) var cliClient + let output = try await cliClient[keyPath: keyPath](args, self) + print(output) } } } + +extension GlobalOptions { + + func shared(_ versionStrategy: CliClient.VersionStrategy) throws -> CliClient.SharedOptions { + try .init( + dryRun: dryRun, + gitDirectory: gitDirectory, + logLevel: .init(verbose: verbose), + target: targetOptions.target(), + versionStrategy: versionStrategy + ) + } + +} + +// MARK: - Helpers + +private extension TargetOptions { + func target() throws -> String { + guard let path else { + guard let module else { + print("Neither target path or module was set.") + throw InvalidTargetOption() + } + + return "\(module)/\(fileName)" + } + return path + } + + struct InvalidTargetOption: Error {} +} + +extension PreReleaseOptions { + + func preReleaseStrategy() throws -> CliClient.PreReleaseStrategy? { + guard let custom else { + if useBranchAsPreRelease { + return .branchAndCommit + } else if useTagAsPreRelease { + return .tag + } else { + return nil + } + } + + if useBranchAsPreRelease { + return .custom(custom, .branchAndCommit) + } else if useTagAsPreRelease { + return .custom(custom, .tag) + } else { + return .custom(custom, nil) + } + } + +} + +extension SemVarOptions { + func semVarOptions() throws -> CliClient.VersionStrategy.SemVarOptions { + try .init( + preReleaseStrategy: preRelease.preReleaseStrategy(), + requireExistingFile: requireExistingFile, + requireExistingSemVar: requireExistingSemvar + ) + } +} diff --git a/Sources/cli-version/UpdateCommand.swift b/Sources/cli-version/UpdateCommand.swift deleted file mode 100644 index 443c21a..0000000 --- a/Sources/cli-version/UpdateCommand.swift +++ /dev/null @@ -1,29 +0,0 @@ -import ArgumentParser -import CliClient -import Dependencies -import Foundation -import ShellClient - -extension CliVersionCommand { - - struct Update: AsyncParsableCommand { - static let 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." - ) - - @OptionGroup var globals: GlobalOptions - - func run() async throws { - try await globals.run { - @Dependency(\.cliClient) var cliClient - let output = try await cliClient.update(globals.shared) - print(output) - } - } - } -} - -private enum UpdateError: Error { - case versionFileDoesNotExist(path: String) -} diff --git a/Tests/CliVersionTests/CliClientTests.swift b/Tests/CliVersionTests/CliClientTests.swift index f626644..d8a6c42 100644 --- a/Tests/CliVersionTests/CliClientTests.swift +++ b/Tests/CliVersionTests/CliClientTests.swift @@ -1,4 +1,4 @@ -@_spi(Internal) import CliVersion +@_spi(Internal) import CliClient import Dependencies import FileClient import Foundation @@ -15,8 +15,13 @@ struct CliClientTests { ) func testBuild(target: String) async throws { try await run { + $0.fileClient.fileExists = { _ in true } + } operation: { @Dependency(\.cliClient) var client - let output = try await client.build(.testOptions(target: target)) + let output = try await client.build(.testOptions( + target: target, + versionStrategy: .semVar(.init(requireExistingFile: false)) + )) #expect(output == "/baz/Sources/bar/Version.swift") } } @@ -45,6 +50,9 @@ struct CliClientTests { #expect(string!.contains("let VERSION: \(typeString) = \"1.1.0\"")) case .patch: #expect(string!.contains("let VERSION: \(typeString) = \"1.0.1\"")) + case .preRelease: + // do something + #expect(Bool(true)) } } } @@ -55,28 +63,14 @@ struct CliClientTests { func generate(target: String) async throws { try await run { @Dependency(\.cliClient) var client - let output = try await client.generate(.testOptions(target: target)) + let output = try await client.build(.testOptions( + target: target, + versionStrategy: .semVar(.init(requireExistingFile: false)) + )) #expect(output == "/baz/Sources/bar/Version.swift") } } - @Test( - arguments: TestArguments.updateCases - ) - func update(target: String, dryRun: Bool) async throws { - try await run { - $0.fileClient.fileExists = { _ in false } - } operation: { - @Dependency(\.cliClient) var client - let output = try await client.update(.testOptions(dryRun: dryRun, target: target)) - #expect(output == "/baz/Sources/bar/Version.swift") - } assert: { string, _ in - if dryRun { - #expect(string == nil) - } - } - } - @Test(arguments: GitClient.Version.mocks) func gitVersionToSemVar(version: GitClient.Version) { let semVar = version.semVar @@ -135,13 +129,15 @@ extension CliClient.SharedOptions { gitDirectory: String? = "/baz", dryRun: Bool = false, target: String = "bar", - logLevel: Logger.Level = .trace + logLevel: Logger.Level = .trace, + versionStrategy: CliClient.VersionStrategy = .semVar(.init()) ) -> Self { .init( - gitDirectory: gitDirectory, dryRun: dryRun, + gitDirectory: gitDirectory, + logLevel: logLevel, target: target, - logLevel: logLevel + versionStrategy: versionStrategy ) } } diff --git a/Tests/CliVersionTests/CliVersionTests.swift b/Tests/CliVersionTests/CliVersionTests.swift index 1eb5258..616ab98 100644 --- a/Tests/CliVersionTests/CliVersionTests.swift +++ b/Tests/CliVersionTests/CliVersionTests.swift @@ -1,4 +1,4 @@ -@_spi(Internal) import CliVersion +@_spi(Internal) import CliClient import Dependencies import FileClient import GitClient