From 9631c62ee34f6682a0540fcdc138ee4109a5e6be Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sat, 28 Dec 2024 17:05:33 -0500 Subject: [PATCH] feat: Updates internal version container used to derive next version in cli-client. --- Package.swift | 1 + Sources/CliClient/CliClientError.swift | 6 +- .../CliClient/Internal/CliClient+run.swift | 104 ++++--- .../Internal/ConfigurationExtensions.swift | 263 ------------------ .../Internal/CurrentVersionContainer.swift | 211 +++++++++----- .../Internal/FileClient+semVar.swift | 55 ++-- .../Internal/Semvar+nextVersion.swift | 90 ++++++ .../LoggingExtensions/LoggingOptions.swift | 14 + Tests/CliVersionTests/CliClientTests.swift | 2 + 9 files changed, 331 insertions(+), 415 deletions(-) create mode 100644 Sources/CliClient/Internal/Semvar+nextVersion.swift diff --git a/Package.swift b/Package.swift index e85c382..d9788e0 100644 --- a/Package.swift +++ b/Package.swift @@ -87,6 +87,7 @@ let package = Package( .target( name: "LoggingExtensions", dependencies: [ + .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "ShellClient", package: "swift-shell-client") ] diff --git a/Sources/CliClient/CliClientError.swift b/Sources/CliClient/CliClientError.swift index 67a2a3d..bf6460e 100644 --- a/Sources/CliClient/CliClientError.swift +++ b/Sources/CliClient/CliClientError.swift @@ -1,8 +1,12 @@ +import ConfigurationClient + enum CliClientError: Error { case gitDirectoryNotFound case fileExists(path: String) case fileDoesNotExist(path: String) case failedToParseVersionFile - case semVarNotFound + case semVarNotFound(message: String) + case strategyNotFound(configuration: Configuration) case preReleaseParsingError(String) + case versionStringNotFound } diff --git a/Sources/CliClient/Internal/CliClient+run.swift b/Sources/CliClient/Internal/CliClient+run.swift index 945352a..60b82de 100644 --- a/Sources/CliClient/Internal/CliClient+run.swift +++ b/Sources/CliClient/Internal/CliClient+run.swift @@ -4,25 +4,29 @@ import Dependencies import FileClient import Foundation import GitClient +import LoggingExtensions -@_spi(Internal) -public extension CliClient.SharedOptions { +extension CliClient.SharedOptions { /// All cli-client calls should run through this, it set's up logging, /// loads configuration, and generates the current version based on the /// configuration. @discardableResult func run( - _ operation: (CurrentVersionContainer) async throws -> Void + _ operation: (VersionContainer) async throws -> Void ) async rethrows -> String { try await loggingOptions.withLogger { // Load the default configuration, if it exists. try await withMergedConfiguration { configuration in @Dependency(\.logger) var logger - var configurationString = "" - customDump(configuration, to: &configurationString) - logger.trace("\nConfiguration: \(configurationString)") + guard let strategy = configuration.strategy else { + throw CliClientError.strategyNotFound(configuration: configuration) + } + + logger.dump(configuration, level: .trace) { + "\nConfiguration: \($0)" + } // This will fail if the target url is not set properly. let targetUrl = try configuration.targetUrl(gitDirectory: projectDirectory) @@ -30,10 +34,7 @@ public extension CliClient.SharedOptions { // Perform the operation, which generates the new version and writes it. try await operation( - configuration.currentVersion( - targetUrl: targetUrl, - gitDirectory: projectDirectory - ) + .load(projectDirectory: projectDirectory, strategy: strategy, url: targetUrl) ) // Return the file path we wrote the version to. @@ -54,10 +55,9 @@ public extension CliClient.SharedOptions { logger.trace("Merging branch strategy.") // strategy = .branch(branch) } else if let semvar = configurationToMerge?.strategy?.semvar { - logger.trace("Merging semvar strategy.") - var semvarString = "" - customDump(semvar, to: &semvarString) - logger.trace("\(semvarString)") + logger.dump(semvar, level: .trace) { + "Merging semvar strategy:\n\($0)" + } } return try await configurationClient.withConfiguration( @@ -75,24 +75,52 @@ public extension CliClient.SharedOptions { try await fileClient.write(string: string, to: url) } else { logger.debug("Skipping, due to dry-run being passed.") - // logger.info("\n\(string)\n") } } - func write(_ currentVersion: CurrentVersionContainer) async throws { + func write(_ currentVersion: VersionContainer) async throws { @Dependency(\.logger) var logger + logger.trace("Begin writing version.") - let version = try currentVersion.version.string(allowPreReleaseTag: allowPreReleaseTag) - if !dryRun { - logger.debug("Version: \(version)") - } else { - logger.info("Version: \(version)") + let hasChanges: Bool + let targetUrl: URL + let usesOptionalType: Bool + let versionString: String? + + switch currentVersion { + case let .branch(branch): + hasChanges = branch.hasChanges + targetUrl = branch.targetUrl + usesOptionalType = branch.usesOptionalType + versionString = branch.versionString + case let .semvar(semvar): + hasChanges = semvar.hasChanges + targetUrl = semvar.targetUrl + usesOptionalType = semvar.usesOptionalType + versionString = semvar.versionString(withPreRelease: allowPreReleaseTag) } - let template = currentVersion.usesOptionalType ? Template.optional(version) : Template.nonOptional(version) + // if !hasChanges { + // logger.debug("No changes from loaded version, not writing next version.") + // return + // } + + guard let versionString else { + throw CliClientError.versionStringNotFound + } + + // let version = try currentVersion.version.string(allowPreReleaseTag: allowPreReleaseTag) + + if !dryRun { + logger.debug("Version: \(versionString)") + } else { + logger.info("Version: \(versionString)") + } + + let template = usesOptionalType ? Template.optional(versionString) : Template.nonOptional(versionString) logger.trace("Template string: \(template)") - try await write(template, to: currentVersion.targetUrl) + try await write(template, to: targetUrl) } } @@ -113,30 +141,20 @@ extension CliClient.SharedOptions { @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. + switch container { + case .branch: // When we did not parse a semvar, just write whatever we parsed for the current version. logger.debug("Failed to parse semvar, but got current version string.") try await write(container) - case let .semvar(semvar, usesOptionalType: usesOptionalType, hasChanges: hasChanges): - logger.debug("Semvar prior to bumping: \(semvar)") - let bumped = semvar.bump(type) - let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag) - - guard bumped != semvar || hasChanges else { - logger.debug("No change, skipping.") - return + case let .semvar(semvar): + // FIX: Fix with an option for precedence. + let version = semvar.loadedVersion ?? semvar.nextVersion + guard let version else { + throw CliClientError.semVarNotFound(message: "Failed to parse a valid semvar to bump.") } - - logger.debug("Bumped version: \(version)") - - if dryRun { - logger.info("Version: \(version)") - return - } - - let template = usesOptionalType ? Template.optional(version) : Template.build(version) - try await write(template, to: container.targetUrl) + logger.debug("Semvar prior to bumping: \(version)") + let bumped = version.bump(type) + try await write(.semvar(semvar.withUpdateNextVersion(bumped))) } } } diff --git a/Sources/CliClient/Internal/ConfigurationExtensions.swift b/Sources/CliClient/Internal/ConfigurationExtensions.swift index 6871fa5..2dae3a7 100644 --- a/Sources/CliClient/Internal/ConfigurationExtensions.swift +++ b/Sources/CliClient/Internal/ConfigurationExtensions.swift @@ -12,174 +12,6 @@ extension Configuration { } return try target.url(gitDirectory: gitDirectory) } - - func currentVersion(targetUrl: URL, gitDirectory: String?) async throws -> CurrentVersionContainer { - guard let strategy else { - throw ConfigurationParsingError.versionStrategyNotFound - } - return try await strategy.currentVersion( - targetUrl: targetUrl, - gitDirectory: gitDirectory - ) - } -} - -private extension Configuration.SemVar.Strategy { - - func getSemvar(gitDirectory: String? = nil) async throws -> SemVar { - @Dependency(\.asyncShellClient) var asyncShellClient - @Dependency(\.gitClient) var gitClient - @Dependency(\.logger) var logger - - let semvar: SemVar? - - switch self { - case let .command(arguments: arguments): - logger.trace("Using custom command strategy with: \(arguments)") - semvar = try await SemVar(string: asyncShellClient.background(.init(arguments))) - case let .gitTag(exactMatch: exactMatch): - logger.trace("Using gitTag strategy.") - semvar = try await gitClient.version(.init( - gitDirectory: gitDirectory, - style: .tag(exactMatch: exactMatch ?? false) - )).semVar - } - - guard let semvar else { - throw CliClientError.semVarNotFound - } - - return semvar - } -} - -@_spi(Internal) -public extension Configuration.SemVar { - - // TODO: Need to handle custom command semvar strategy. - - func currentVersion(file: URL, gitDirectory: String? = nil) async throws -> CurrentVersionContainer.Version { - @Dependency(\.fileClient) var fileClient - @Dependency(\.gitClient) var gitClient - @Dependency(\.logger) var logger - - let fileOutput = try? await fileClient.semvar(file: file, gitDirectory: gitDirectory) - var semVar = fileOutput?.semVar - - logger.trace("file output semvar: \(String(describing: semVar))") - - let usesOptionalType = fileOutput?.usesOptionalType - - // We parsed a semvar from the existing file, use it. - if semVar != nil { - let semvarWithPreRelease = try await applyingPreRelease(semVar!, gitDirectory) - - return .semvar( - semvarWithPreRelease, - usesOptionalType: usesOptionalType ?? false, - hasChanges: semvarWithPreRelease != semVar - ) - } - - if requireExistingFile == true { - logger.debug("Failed to parse existing file, and caller requires it.") - throw CliClientError.fileDoesNotExist(path: file.cleanFilePath) - } - - logger.trace("Does not require existing file, checking git-tag.") - - // TODO: Is this what we want to do here? Seems that the strategy should be set by the client / configuration. - - // 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 semVar != nil { - let semvarWithPreRelease = try await applyingPreRelease(semVar!, gitDirectory) - return .semvar( - semvarWithPreRelease, - usesOptionalType: usesOptionalType ?? false, - hasChanges: semvarWithPreRelease != semVar - ) - } - - if requireExistingSemVar == true { - logger.trace("Caller requires existing semvar and it was not found in file or git-tag.") - throw CliClientError.semVarNotFound - } - - // Semvar doesn't exist, so create a new one. - logger.trace("Generating new semvar.") - return try await .semvar( - applyingPreRelease(.init(), gitDirectory), - usesOptionalType: usesOptionalType ?? false, - hasChanges: true - ) - } -} - -extension Configuration.VersionStrategy { - - func loadNextVersion(gitDirectory: String?) async throws -> CurrentVersionContainer.Version { - @Dependency(\.gitClient) var gitClient - @Dependency(\.logger) var logger - - switch self { - case let .branch(includeCommitSha: includeCommitSha): - logger.trace("Loading next version for branch strategy...") - let next = try await gitClient.version(includeCommitSha: includeCommitSha, gitDirectory: gitDirectory) - logger.trace("Next version: \(next)") - return .string(next) - case .semvar: - logger.trace("Loading next version for semvar strategy...") - let semvar = semvar! - guard let strategy = semvar.strategy else { - // TODO: Error here. - fatalError() - } - let next = try await strategy.getSemvar(gitDirectory: gitDirectory) - var nextString = "" - customDump(next, to: &nextString) - logger.trace("Next version:\n\(nextString)") - - // TODO: Need to check for pre-release and load it here. - - // TODO: Fix optional type / has changes. - return .semvar(next, usesOptionalType: true, hasChanges: true) - } - } -} - -private extension Configuration.VersionStrategy { - - // TODO: This should just load the `nextVersion`, and should probably live on CurrentVersionContainer. - - // FIX: Fix what's passed to current verions here. - func currentVersion(targetUrl: URL, gitDirectory: String?) async throws -> CurrentVersionContainer { - @Dependency(\.gitClient) var gitClient - - guard let branch else { - guard let semvar else { - throw ConfigurationParsingError.versionStrategyError( - message: "Neither branch nor semvar set on configuration." - ) - } - return try await .init( - targetUrl: targetUrl, - currentVersion: nil, - version: semvar.currentVersion(file: targetUrl, gitDirectory: gitDirectory) - ) - } - return try await .init( - targetUrl: targetUrl, - currentVersion: nil, - version: .string( - gitClient.version(includeCommitSha: branch.includeCommitSha, gitDirectory: gitDirectory) - ) - ) - } } private extension Configuration.Target { @@ -217,101 +49,6 @@ private extension Configuration.Target { } } -private extension GitClient { - func version(includeCommitSha: Bool, gitDirectory: String?) async throws -> String { - @Dependency(\.gitClient) var gitClient - - return try await gitClient.version(.init( - gitDirectory: gitDirectory, - style: .branch(commitSha: includeCommitSha) - )).description - } -} - -private extension Configuration.PreRelease { - - func preReleaseString(gitDirectory: String?) async throws -> PreReleaseString? { - guard let strategy else { return nil } - - @Dependency(\.asyncShellClient) var asyncShellClient - @Dependency(\.gitClient) var gitClient - @Dependency(\.logger) var logger - - var preReleaseString: String - var suffix = true - var allowsPrefix = true - - switch strategy { - case let .branch(includeCommitSha: includeCommitSha): - logger.trace("Branch pre-release strategy, includeCommitSha: \(includeCommitSha).") - preReleaseString = try await gitClient.version( - includeCommitSha: includeCommitSha, - gitDirectory: gitDirectory - ) - case let .command(arguments: arguments, allowPrefix: allowPrefix): - logger.trace("Command pre-release strategy, arguments: \(arguments).") - preReleaseString = try await asyncShellClient.background(.init(arguments)) - allowsPrefix = allowPrefix ?? false - case .gitTag: - logger.trace("Git tag pre-release strategy.") - logger.trace("This will ignore any set prefix.") - suffix = false - allowsPrefix = false - preReleaseString = try await gitClient.version(.init( - gitDirectory: gitDirectory, - style: .tag(exactMatch: false) - )).description - } - - if let prefix, allowsPrefix { - preReleaseString = "\(prefix)-\(preReleaseString)" - } - - guard suffix else { return .semvar(preReleaseString) } - return .suffix(preReleaseString) - } - - enum PreReleaseString: Sendable { - case suffix(String) - case semvar(String) - } -} - -private extension Configuration.SemVar { - - // TODO: Move this to SemVar, not Configuration.SemVar - func applyingPreRelease(_ semvar: SemVar, _ gitDirectory: String?) async throws -> SemVar { - @Dependency(\.logger) var logger - logger.trace("Start apply pre-release to: \(semvar)") - - guard let preReleaseStrategy = self.preRelease, - let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory) - else { - logger.trace("No pre-release strategy, returning original semvar.") - return semvar - } - - logger.trace("Pre-release string: \(preRelease)") - - switch preRelease { - case let .suffix(string): - return semvar.applyingPreRelease(string) - case let .semvar(string): - guard let semvar = SemVar(string: string) else { - throw CliClientError.preReleaseParsingError(string) - } - if let prefix = self.preRelease?.prefix { - var prefixString = prefix - if let preReleaseString = semvar.preRelease { - prefixString = "\(prefix)-\(preReleaseString)" - } - return semvar.applyingPreRelease(prefixString) - } - return semvar - } - } -} - enum ConfigurationParsingError: Error { case targetNotFound case pathOrModuleNotSet diff --git a/Sources/CliClient/Internal/CurrentVersionContainer.swift b/Sources/CliClient/Internal/CurrentVersionContainer.swift index d025604..3acb70a 100644 --- a/Sources/CliClient/Internal/CurrentVersionContainer.swift +++ b/Sources/CliClient/Internal/CurrentVersionContainer.swift @@ -3,101 +3,160 @@ import CustomDump import Dependencies import FileClient import Foundation +import LoggingExtensions -// TODO: Add optional property for currentVersion (loaded version from file) -// and rename version to nextVersion. +enum VersionContainer: Sendable { + case branch(CurrentVersionContainer2) + case semvar(CurrentVersionContainer2) -/// An internal type that holds onto the loaded version from a file (if found), -/// the computed next version, and the target file url. -/// -@_spi(Internal) -public struct CurrentVersionContainer: Sendable { - - let targetUrl: URL - let currentVersion: CurrentVersion? - let version: Version - - // TODO: Derive from current version. - var usesOptionalType: Bool { - switch version { - case .string: return false - case let .semvar(_, usesOptionalType, _): return usesOptionalType - } - } - - var hasChanges: Bool { - guard let currentVersion else { return false } - switch (currentVersion, version) { - case let (.branch(currentString, _), .string(nextString)): - return currentString == nextString - case let (.semvar(currentSemvar, _), .semvar(nextSemvar, _, _)): - return currentSemvar == nextSemvar - // TODO: What to do with mis-matched values. - case (.branch, .semvar), - (.semvar, .string): - return true - } - } - - public enum CurrentVersion: Sendable { - case branch(String, usesOptionalType: Bool) - case semvar(SemVar, usesOptionalType: Bool) - } - - public enum Version: Sendable { - // TODO: Call this branch for consistency. - case string(String) - // TODO: Remove has changes when currentVersion/nextVersion is implemented. - case semvar(SemVar, usesOptionalType: Bool = true, hasChanges: Bool) - - func string(allowPreReleaseTag: Bool) throws -> String { - switch self { - case let .string(string): - return string - case let .semvar(semvar, usesOptionalType: _, hasChanges: _): - return semvar.versionString(withPreReleaseTag: allowPreReleaseTag) - } + static func load( + projectDirectory: String?, + strategy: Configuration.VersionStrategy, + url: URL + ) async throws -> Self { + switch strategy { + case let .branch(includeCommitSha: includeCommitSha): + return try await .branch(.load( + branch: .init(includeCommitSha: includeCommitSha), + gitDirectory: projectDirectory, + url: url + )) + case .semvar: + return try await .semvar(.load( + semvar: strategy.semvar!, + gitDirectory: projectDirectory, + url: url + )) } } } -extension CurrentVersionContainer { +struct CurrentVersionContainer2 { + let targetUrl: URL + let usesOptionalType: Bool + let loadedVersion: Version? + // TODO: Rename to strategyVersion + let nextVersion: Version? +} + +extension CurrentVersionContainer2: Equatable where Version: Equatable { + + var hasChanges: Bool { + switch (loadedVersion, nextVersion) { + case (.none, .none): + return false + case (.some, .none), + (.none, .some): + return true + case let (.some(loaded), .some(next)): + return loaded == next + } + } +} + +extension CurrentVersionContainer2: Sendable where Version: Sendable {} + +extension CurrentVersionContainer2 where Version == String { static func load( - configuration: Configuration, - sharedOptions: CliClient.SharedOptions + branch: Configuration.Branch, + gitDirectory: String?, + url: URL ) async throws -> Self { + @Dependency(\.fileClient) var fileClient + @Dependency(\.gitClient) var gitClient + + let loaded = try await fileClient.branch( + file: url, + gitDirectory: gitDirectory, + requireExistingFile: false + ) + + let next = try await gitClient.version(.init( + gitDirectory: gitDirectory, + style: .branch(commitSha: branch.includeCommitSha) + )) + + return .init( + targetUrl: url, + usesOptionalType: loaded?.1 ?? true, + loadedVersion: loaded?.0, + nextVersion: next.description + ) + } + + var versionString: String? { + loadedVersion ?? nextVersion + } +} + +extension CurrentVersionContainer2 where Version == SemVar { + + static func load(semvar: Configuration.SemVar, gitDirectory: String?, url: URL) async throws -> Self { @Dependency(\.fileClient) var fileClient @Dependency(\.logger) var logger - let targetUrl = try configuration.targetUrl(gitDirectory: sharedOptions.projectDirectory) - logger.trace("Begin loading current version from: \(targetUrl.cleanFilePath)") + logger.trace("Begin loading semvar from: \(url.cleanFilePath)") - let currentVersion = try await fileClient.loadCurrentVersion( - url: targetUrl, - gitDirectory: sharedOptions.projectDirectory, - expectsBranch: configuration.strategy?.branch != nil + async let (loaded, usesOptionalType) = try await loadCurrentVersion( + semvar: semvar, + gitDirectory: gitDirectory, + url: url ) - var currentVersionString = "" - customDump(currentVersion, to: ¤tVersionString) - logger.trace("Loaded current version:\n\(currentVersionString)") + async let next = try await loadNextVersion(semvar: semvar, projectDirectory: gitDirectory) - if configuration.strategy?.semvar?.requireExistingFile == true { - guard currentVersion != nil else { - // TODO: Better error. - throw CliClientError.semVarNotFound + return try await .init( + targetUrl: url, + usesOptionalType: usesOptionalType, + loadedVersion: loaded, + nextVersion: next + ) + } + + static func loadCurrentVersion( + semvar: Configuration.SemVar, + gitDirectory: String?, + url: URL + ) async throws -> (SemVar?, Bool) { + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + + logger.trace("Begin loading current version from: \(url.cleanFilePath)") + + let loadedOptional = try await fileClient.semvar( + file: url, + gitDirectory: gitDirectory, + requireExistingFile: semvar.requireExistingFile ?? false + ) + guard let loadedStrong = loadedOptional else { + if semvar.requireExistingFile ?? false { + throw CliClientError.semVarNotFound(message: "Required by configuration's 'requireExistingFile' variable.") } + return (nil, true) } - // Check that there's a valid strategy to get the next version. - guard let strategy = configuration.strategy else { - // TODO: Return without a next version here when nextVersion is optional. - fatalError() - } + let (loaded, usesOptionalType) = loadedStrong - // TODO: make optional? - let next = try await strategy.loadNextVersion(gitDirectory: sharedOptions.projectDirectory) + logger.dump(loaded) { "Loaded version:\n\($0)" } + return (loaded, usesOptionalType) + } - fatalError() + static func loadNextVersion(semvar: Configuration.SemVar, projectDirectory: String?) async throws -> SemVar? { + @Dependency(\.logger) var logger + let next = try await SemVar.nextVersion( + configuration: semvar, + projectDirectory: projectDirectory + ) + logger.dump(next) { "Next version:\n\($0)" } + return next + } + + func versionString(withPreRelease: Bool) -> String? { + nextVersion?.versionString(withPreReleaseTag: withPreRelease) + ?? loadedVersion?.versionString(withPreReleaseTag: withPreRelease) + } + + func withUpdateNextVersion(_ next: SemVar) -> Self { + .init(targetUrl: targetUrl, usesOptionalType: usesOptionalType, loadedVersion: loadedVersion, nextVersion: next) } } diff --git a/Sources/CliClient/Internal/FileClient+semVar.swift b/Sources/CliClient/Internal/FileClient+semVar.swift index e1d250a..c22da33 100644 --- a/Sources/CliClient/Internal/FileClient+semVar.swift +++ b/Sources/CliClient/Internal/FileClient+semVar.swift @@ -5,44 +5,35 @@ import GitClient @_spi(Internal) public extension FileClient { - - func loadCurrentVersion( - url: URL, - gitDirectory: String?, - expectsBranch: Bool - ) async throws -> CurrentVersionContainer.CurrentVersion? { - @Dependency(\.logger) var logger - - switch expectsBranch { - case true: - let (string, usesOptionalType) = try await branch(file: url, gitDirectory: gitDirectory) - logger.debug("Loaded branch: \(string)") - return .branch(string, usesOptionalType: usesOptionalType) - case false: - let (semvar, usesOptionalType) = try await semvar(file: url, gitDirectory: gitDirectory) - guard let semvar else { return nil } - logger.debug("Semvar: \(semvar)") - return .semvar(semvar, usesOptionalType: usesOptionalType) - } - } - - // TODO: Make private. func branch( file: URL, - gitDirectory: String? - ) async throws -> (string: String, usesOptionalType: Bool) { - let (string, usesOptionalType) = try await getVersionString(fileUrl: file, gitDirectory: gitDirectory) - return (string, usesOptionalType) + gitDirectory: String?, + requireExistingFile: Bool + ) async throws -> (string: String, usesOptionalType: Bool)? { + let loaded = try? await getVersionString(fileUrl: file, gitDirectory: gitDirectory) + guard let loaded else { + if requireExistingFile { + throw CliClientError.fileDoesNotExist(path: file.cleanFilePath) + } + return nil + } + return (loaded.0, loaded.1) } - // TODO: Make private. func semvar( file: URL, - gitDirectory: String? - ) async throws -> (semVar: SemVar?, usesOptionalType: Bool) { - let (string, usesOptionalType) = try await getVersionString(fileUrl: file, gitDirectory: gitDirectory) - let semvar = SemVar(string: string) - return (semvar, usesOptionalType) + gitDirectory: String?, + requireExistingFile: Bool + ) async throws -> (semVar: SemVar?, usesOptionalType: Bool)? { + let loaded = try? await getVersionString(fileUrl: file, gitDirectory: gitDirectory) + guard let loaded else { + if requireExistingFile { + throw CliClientError.fileDoesNotExist(path: file.cleanFilePath) + } + return nil + } + let semvar = SemVar(string: loaded.0) + return (semvar, loaded.1) } private func getVersionString( diff --git a/Sources/CliClient/Internal/Semvar+nextVersion.swift b/Sources/CliClient/Internal/Semvar+nextVersion.swift new file mode 100644 index 0000000..efe56fd --- /dev/null +++ b/Sources/CliClient/Internal/Semvar+nextVersion.swift @@ -0,0 +1,90 @@ +import ConfigurationClient +import Foundation +import GitClient +import LoggingExtensions +import ShellClient + +extension SemVar { + static func nextVersion( + configuration: Configuration.SemVar, + projectDirectory: String? + ) async throws -> Self? { + @Dependency(\.asyncShellClient) var asyncShellClient + @Dependency(\.gitClient) var gitClient + @Dependency(\.logger) var logger + + guard let strategy = configuration.strategy else { return nil } + + let semvarString: String + + switch strategy { + case let .gitTag(exactMatch: exactMatch): + logger.trace("Loading semvar gitTag strategy...") + + semvarString = try await gitClient.version(.init( + gitDirectory: projectDirectory, + style: .tag(exactMatch: exactMatch ?? false) + )).description + + case let .command(arguments: arguments): + logger.trace("Loading semvar custom command strategy: \(arguments)") + semvarString = try await asyncShellClient.background(.init(arguments)) + } + + var preReleaseString: String? + if let preRelease = configuration.preRelease, + configuration.allowPreRelease ?? true + { + preReleaseString = try await preRelease.get(projectDirectory: projectDirectory) + } + + let semvar = SemVar(string: semvarString) + + if let preReleaseString { + return semvar?.applyingPreRelease(preReleaseString) + } + + return semvar + } +} + +private extension Configuration.PreRelease { + + func get(projectDirectory: String?) async throws -> String? { + @Dependency(\.asyncShellClient) var asyncShellClient + @Dependency(\.gitClient) var gitClient + @Dependency(\.logger) var logger + + var allowsPrefix = true + var preReleaseString: String + + guard let strategy else { return nil } + switch strategy { + case let .branch(includeCommitSha: includeCommitSha): + logger.trace("Loading pre-relase branch strategy...") + preReleaseString = try await gitClient.version(.init( + gitDirectory: projectDirectory, + style: .branch(commitSha: includeCommitSha) + )).description + + case .gitTag: + logger.trace("Loading pre-relase gitTag strategy...") + preReleaseString = try await gitClient.version(.init( + gitDirectory: projectDirectory, + style: .tag(exactMatch: false) + )).description + + case let .command(arguments: arguments, allowPrefix: allowPrefix): + logger.trace("Loading pre-relase custom command strategy...") + allowsPrefix = allowPrefix ?? false + preReleaseString = try await asyncShellClient.background(.init(arguments)) + } + + if let prefix, allowsPrefix { + preReleaseString = "\(prefix)-\(preReleaseString)" + } + + logger.trace("Pre-release string: \(preReleaseString)") + return preReleaseString + } +} diff --git a/Sources/LoggingExtensions/LoggingOptions.swift b/Sources/LoggingExtensions/LoggingOptions.swift index db4b929..adca0ce 100644 --- a/Sources/LoggingExtensions/LoggingOptions.swift +++ b/Sources/LoggingExtensions/LoggingOptions.swift @@ -1,4 +1,6 @@ +import CustomDump import Dependencies +import Logging import ShellClient public struct LoggingOptions: Equatable, Sendable { @@ -26,3 +28,15 @@ public struct LoggingOptions: Equatable, Sendable { } } } + +public extension Logger { + func dump( + _ type: T, + level: Level = .trace, + buildMessage: @escaping (String) -> String = { $0 } + ) { + var message = "" + customDump(type, to: &message) + log(level: level, "\(buildMessage(message))") + } +} diff --git a/Tests/CliVersionTests/CliClientTests.swift b/Tests/CliVersionTests/CliClientTests.swift index bf9f8f5..aadd277 100644 --- a/Tests/CliVersionTests/CliClientTests.swift +++ b/Tests/CliVersionTests/CliClientTests.swift @@ -15,8 +15,10 @@ struct CliClientTests { arguments: TestArguments.testCases ) func testBuild(target: String) async throws { + let template = Template.build("1.0.0") try await run { $0.fileClient.fileExists = { _ in true } + $0.fileClient.read = { @Sendable _ in template } } operation: { @Dependency(\.cliClient) var client let output = try await client.build(.testOptions(