Compare commits

7 Commits

Author SHA1 Message Date
016f0d6c3f feat: Some parameter renaming for consistency
All checks were successful
CI / Ubuntu (push) Successful in 2m36s
2024-12-31 08:31:41 -05:00
147f6df1b3 feat: Test updates
All checks were successful
CI / Ubuntu (push) Successful in 2m37s
2024-12-31 08:04:04 -05:00
5c22250a63 feat: Adds documentation for precedence.
All checks were successful
CI / Ubuntu (push) Successful in 2m29s
2024-12-29 00:01:18 -05:00
9dd30a1745 feat: Integrates precedence with command-line options, needs documentation.
All checks were successful
CI / Ubuntu (push) Successful in 2m31s
2024-12-28 23:43:56 -05:00
f1eb883b93 feat: Integrates a precedence configuration setting, needs a command-line option.
All checks were successful
CI / Ubuntu (push) Successful in 2m47s
2024-12-28 22:15:24 -05:00
9631c62ee3 feat: Updates internal version container used to derive next version in cli-client. 2024-12-28 17:05:33 -05:00
6fe459c39e feat: Prep to update current version container in cli-client. 2024-12-28 10:09:34 -05:00
18 changed files with 542 additions and 421 deletions

View File

@@ -55,6 +55,7 @@ let package = Package(
name: "ConfigurationClient", name: "ConfigurationClient",
dependencies: [ dependencies: [
"FileClient", "FileClient",
"LoggingExtensions",
.product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies") .product(name: "DependenciesMacros", package: "swift-dependencies")
@@ -87,6 +88,7 @@ let package = Package(
.target( .target(
name: "LoggingExtensions", name: "LoggingExtensions",
dependencies: [ dependencies: [
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client") .product(name: "ShellClient", package: "swift-shell-client")
] ]

View File

@@ -60,6 +60,7 @@ extension ConfigCommand {
@OptionGroup var globals: ConfigCommandOptions @OptionGroup var globals: ConfigCommandOptions
func run() async throws { func run() async throws {
@Dependency(\.logger) var logger
let configuration = try await globals let configuration = try await globals
.shared(command: Self.commandName) .shared(command: Self.commandName)
.runClient(\.parsedConfiguration) .runClient(\.parsedConfiguration)
@@ -183,7 +184,11 @@ private extension ConfigCommand.DumpConfig {
private extension ConfigCommand.ConfigCommandOptions { private extension ConfigCommand.ConfigCommandOptions {
func shared(command: String) throws -> CliClient.SharedOptions { func shared(command: String) throws -> CliClient.SharedOptions {
try configOptions.shared(command: command, extraOptions: extraOptions, verbose: verbose) try configOptions.shared(
command: command,
extraOptions: extraOptions,
verbose: verbose
)
} }
func handlePrintJson(_ configuration: Configuration) throws { func handlePrintJson(_ configuration: Configuration) throws {

View File

@@ -19,18 +19,24 @@ of their usage.
### Configuration Options ### Configuration Options
| Short | Long | Argument | Description | | Short | Long | Argument | Description |
| ----- | ---------------------------------- | ----------- | ---------------------------------------------------------------------------------- | | ----- | ---------------------------------- | ------------ | ----------------------------------------------------------------------------------- |
| -f | --configuration-file | <path> | The path to the configuration to use. | | -f | --configuration-file | <path> | The path to the configuration to use. |
| -m | --target-module | <name> | The target module name inside your project | | -m | --target-module | <name> | The target module name inside your project |
| -n | --target-file-name | <name> | The file name for the version to be found inside the module | | -n | --target-file-name | <name> | The file name for the version to be found inside the module |
| -p | --target-file-path | <path> | Path to a version file in your project | | -p | --target-file-path | <path> | Path to a version file in your project |
| N/A | --enable-git-tag/--disable-git-tag | N/A | Use the git-tag version strategy | | N/A | --enable-git-tag/--disable-git-tag | N/A | Use the git-tag version strategy |
| N/A | --require-exact-match | N/A | Fail if a tag is not specifically set on the commit | | N/A | --require-exact-match | N/A | Fail if a tag is not specifically set on the commit |
| N/A | --require-existing-semvar | N/A | Fail if an existing semvar is not found in the version file. | | N/A | --require-existing-semvar | N/A | Fail if an existing semvar is not found in the version file. |
| -c | --custom-command | <arguments> | Use a custom command strategy for the version (any options need to proceed a '--') | | -c | --custom-command | <arguments> | Use a custom command strategy for the version (any options need to proceed a '--') |
| N/A | --commit-sha/--no-commit-sha | N/A | Use the commit sha with branch version or pre-release strategy | | N/A | --commit-sha/--no-commit-sha | N/A | Use the commit sha with branch version or pre-release strategy |
| N/A | --require-configuration | N/A | Fail if a configuration file is not found | | N/A | --require-configuration | N/A | Fail if a configuration file is not found |
| N/A | --precedence | <precedence> | The precedence for when a file exists (values: ['file', 'strategy'], default: file) |
> Note: Precedence is used as tie breaker if the version in the file does not agree with the version
> from the configured strategy. This can happen if a file was edited / bumped manually or the value
> returned from the external command is not similar to the version in the file. By default the file
> will take precedence over what is returned from the strategy.
#### Pre-Release Options #### Pre-Release Options

View File

@@ -173,5 +173,19 @@ struct SemVarOptions: ParsableArguments {
) )
var customCommand: Bool = false var customCommand: Bool = false
@Option(
name: .long,
help: """
Set the precence to prefer version from file or strategy.
"""
)
var precedence: Configuration.SemVar.Precedence?
@OptionGroup var preRelease: PreReleaseOptions @OptionGroup var preRelease: PreReleaseOptions
} }
extension Configuration.SemVar.Precedence: ExpressibleByArgument {
public init?(argument: String) {
self.init(rawValue: argument)
}
}

View File

@@ -136,8 +136,11 @@ extension SemVarOptions {
""") """)
} }
logger.trace("precedence: \(String(describing: precedence))")
return try .init( return try .init(
allowPreRelease: !preRelease.disablePreRelease, allowPreRelease: !preRelease.disablePreRelease,
precedence: precedence,
preRelease: customCommand ? nil : preRelease.configPreReleaseStrategy( preRelease: customCommand ? nil : preRelease.configPreReleaseStrategy(
includeCommitSha: includeCommitSha, includeCommitSha: includeCommitSha,
extraOptions: extraOptions extraOptions: extraOptions

View File

@@ -24,7 +24,7 @@ public struct CliClient: Sendable {
public var build: @Sendable (SharedOptions) async throws -> String public var build: @Sendable (SharedOptions) async throws -> String
/// Bump the existing version. /// 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. /// Generate a version file with an optional version that can be set manually.
public var generate: @Sendable (SharedOptions) async throws -> String public var generate: @Sendable (SharedOptions) async throws -> String

View File

@@ -1,8 +1,12 @@
import ConfigurationClient
enum CliClientError: Error { enum CliClientError: Error {
case gitDirectoryNotFound case gitDirectoryNotFound
case fileExists(path: String) case fileExists(path: String)
case fileDoesNotExist(path: String) case fileDoesNotExist(path: String)
case failedToParseVersionFile case failedToParseVersionFile
case semVarNotFound case semVarNotFound(message: String)
case strategyNotFound(configuration: Configuration)
case preReleaseParsingError(String) case preReleaseParsingError(String)
case versionStringNotFound
} }

View File

@@ -4,36 +4,37 @@ import Dependencies
import FileClient import FileClient
import Foundation import Foundation
import GitClient import GitClient
import LoggingExtensions
@_spi(Internal) extension CliClient.SharedOptions {
public extension CliClient.SharedOptions {
/// All cli-client calls should run through this, it set's up logging, /// All cli-client calls should run through this, it set's up logging,
/// loads configuration, and generates the current version based on the /// loads configuration, and generates the current version based on the
/// configuration. /// configuration.
@discardableResult @discardableResult
func run( func run(
_ operation: (CurrentVersionContainer) async throws -> Void _ operation: (VersionContainer) async throws -> Void
) async rethrows -> String { ) async rethrows -> String {
try await loggingOptions.withLogger { try await loggingOptions.withLogger {
// Load the default configuration, if it exists. // Load the default configuration, if it exists.
try await withMergedConfiguration { configuration in try await withMergedConfiguration { configuration in
@Dependency(\.logger) var logger @Dependency(\.logger) var logger
var configurationString = "" guard let strategy = configuration.strategy else {
customDump(configuration, to: &configurationString) throw CliClientError.strategyNotFound(configuration: configuration)
logger.trace("\nConfiguration: \(configurationString)") }
logger.dump(configuration, level: .trace) {
"\nConfiguration: \($0)"
}
// This will fail if the target url is not set properly. // This will fail if the target url is not set properly.
let targetUrl = try configuration.targetUrl(gitDirectory: projectDirectory) let targetUrl = try configuration.targetUrl(projectDirectory: projectDirectory)
logger.debug("Target: \(targetUrl.cleanFilePath)") logger.debug("Target: \(targetUrl.cleanFilePath)")
// Perform the operation, which generates the new version and writes it. // Perform the operation, which generates the new version and writes it.
try await operation( try await operation(
configuration.currentVersion( .load(projectDirectory: projectDirectory, strategy: strategy, url: targetUrl)
targetUrl: targetUrl,
gitDirectory: projectDirectory
)
) )
// Return the file path we wrote the version to. // Return the file path we wrote the version to.
@@ -54,10 +55,9 @@ public extension CliClient.SharedOptions {
logger.trace("Merging branch strategy.") logger.trace("Merging branch strategy.")
// strategy = .branch(branch) // strategy = .branch(branch)
} else if let semvar = configurationToMerge?.strategy?.semvar { } else if let semvar = configurationToMerge?.strategy?.semvar {
logger.trace("Merging semvar strategy.") logger.dump(semvar, level: .trace) {
var semvarString = "" "Merging semvar strategy:\n\($0)"
customDump(semvar, to: &semvarString) }
logger.trace("\(semvarString)")
} }
return try await configurationClient.withConfiguration( return try await configurationClient.withConfiguration(
@@ -75,63 +75,52 @@ public extension CliClient.SharedOptions {
try await fileClient.write(string: string, to: url) try await fileClient.write(string: string, to: url)
} else { } else {
logger.debug("Skipping, due to dry-run being passed.") 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 @Dependency(\.logger) var logger
logger.trace("Begin writing version.")
let version = try currentVersion.version.string(allowPreReleaseTag: allowPreReleaseTag) // let hasChanges: Bool
if !dryRun { let targetUrl: URL
logger.debug("Version: \(version)") let usesOptionalType: Bool
} else { let versionString: String?
logger.info("Version: \(version)")
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)") logger.trace("Template string: \(template)")
try await write(template, to: currentVersion.targetUrl) try await write(template, to: targetUrl)
}
}
// TODO: Add optional property for currentVersion (loaded version from file)
// and rename version to nextVersion.
@_spi(Internal)
public struct CurrentVersionContainer: Sendable {
let targetUrl: URL
let currentVersion: CurrentVersion?
let version: Version
var usesOptionalType: Bool {
switch version {
case .string: return false
case let .semvar(_, usesOptionalType, _): return usesOptionalType
}
}
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)
}
}
} }
} }
@@ -143,39 +132,35 @@ extension CliClient.SharedOptions {
} }
} }
func bump(_ type: CliClient.BumpOption?) async throws -> String { func bump(_ type: CliClient.BumpOption) async throws -> String {
guard let type else {
return try await generate()
}
return try await run { container in return try await run { container in
@Dependency(\.logger) var logger @Dependency(\.logger) var logger
switch container.version { switch container {
case .string: // When we did not parse a semvar, just write whatever we parsed for the current version. 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.") logger.debug("Failed to parse semvar, but got current version string.")
try await write(container) try await write(container)
case let .semvar(semvar, usesOptionalType: usesOptionalType, hasChanges: hasChanges): case let .semvar(semvar):
logger.debug("Semvar prior to bumping: \(semvar)")
let bumped = semvar.bump(type)
let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag)
guard bumped != semvar || hasChanges else { let version: SemVar?
logger.debug("No change, skipping.")
return switch semvar.precedence ?? .default {
case .file:
version = semvar.loadedVersion ?? semvar.strategyVersion
case .strategy:
version = semvar.strategyVersion ?? semvar.loadedVersion
} }
logger.debug("Bumped version: \(version)") // let version = semvar.loadedVersion ?? semvar.nextVersion
guard let version else {
if dryRun { throw CliClientError.semVarNotFound(message: "Failed to parse a valid semvar to bump.")
logger.info("Version: \(version)")
return
} }
logger.dump(version, level: .debug) { "Version prior to bumping:\n\($0)" }
let template = usesOptionalType ? Template.optional(version) : Template.build(version) let bumped = version.bump(type)
try await write(template, to: container.targetUrl) logger.dump(bumped, level: .trace) { "Bumped version:\n\($0)" }
try await write(.semvar(semvar.withUpdateNextVersion(bumped)))
} }
} }
} }
@@ -186,3 +171,15 @@ extension CliClient.SharedOptions {
} }
} }
} }
private extension CurrentVersionContainer where Version == SemVar {
func withUpdateNextVersion(_ next: SemVar) -> Self {
.init(
targetUrl: targetUrl,
usesOptionalType: usesOptionalType,
loadedVersion: loadedVersion,
precedence: .strategy, // make sure to use the next version, since it was specified, as this is called from `bump`.
strategyVersion: next
)
}
}

View File

@@ -0,0 +1,57 @@
import ConfigurationClient
import CustomDump
import Dependencies
import Foundation
import GitClient
import ShellClient
extension Configuration {
func targetUrl(projectDirectory: String?) throws -> URL {
guard let target else {
throw ConfigurationParsingError.targetNotFound
}
return try target.url(projectDirectory: projectDirectory)
}
}
private extension Configuration.Target {
func url(projectDirectory: String?) throws -> URL {
@Dependency(\.logger) var logger
let filePath: String
if let path {
filePath = path
} else {
guard let module else {
throw ConfigurationParsingError.pathOrModuleNotSet
}
var path = module.name
logger.debug("module.name: \(path)")
if path.hasPrefix("./") {
path = String(path.dropFirst(2))
}
if !path.hasPrefix("Sources") {
logger.debug("no prefix")
path = "Sources/\(path)"
}
filePath = "\(path)/\(module.fileNameOrDefault)"
}
if let projectDirectory {
return URL(filePath: "\(projectDirectory)/\(filePath)")
}
return URL(filePath: filePath)
}
}
enum ConfigurationParsingError: Error {
case targetNotFound
case pathOrModuleNotSet
case versionStrategyError(message: String)
case versionStrategyNotFound
}

View File

@@ -1,286 +0,0 @@
import ConfigurationClient
import Dependencies
import Foundation
import GitClient
import ShellClient
extension Configuration {
func targetUrl(gitDirectory: String?) throws -> URL {
guard let target else {
throw ConfigurationParsingError.targetNotFound
}
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
)
}
}
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 {
func url(gitDirectory: String?) throws -> URL {
@Dependency(\.logger) var logger
let filePath: String
if let path {
filePath = path
} else {
guard let module else {
throw ConfigurationParsingError.pathOrModuleNotSet
}
var path = module.name
logger.debug("module.name: \(path)")
if path.hasPrefix("./") {
path = String(path.dropFirst(2))
}
if !path.hasPrefix("Sources") {
logger.debug("no prefix")
path = "Sources/\(path)"
}
filePath = "\(path)/\(module.fileNameOrDefault)"
}
if let gitDirectory {
return URL(filePath: "\(gitDirectory)/\(filePath)")
}
return URL(filePath: filePath)
}
}
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 {
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
case versionStrategyError(message: String)
case versionStrategyNotFound
}

View File

@@ -5,49 +5,40 @@ import GitClient
@_spi(Internal) @_spi(Internal)
public extension FileClient { 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( func branch(
file: URL, file: URL,
gitDirectory: String? projectDirectory: String?,
) async throws -> (string: String, usesOptionalType: Bool) { requireExistingFile: Bool
let (string, usesOptionalType) = try await getVersionString(fileUrl: file, gitDirectory: gitDirectory) ) async throws -> (string: String, usesOptionalType: Bool)? {
return (string, usesOptionalType) let loaded = try? await getVersionString(fileUrl: file, projectDirectory: projectDirectory)
guard let loaded else {
if requireExistingFile {
throw CliClientError.fileDoesNotExist(path: file.cleanFilePath)
}
return nil
}
return (loaded.0, loaded.1)
} }
// TODO: Make private.
func semvar( func semvar(
file: URL, file: URL,
gitDirectory: String? projectDirectory: String?,
) async throws -> (semVar: SemVar?, usesOptionalType: Bool) { requireExistingFile: Bool
let (string, usesOptionalType) = try await getVersionString(fileUrl: file, gitDirectory: gitDirectory) ) async throws -> (semVar: SemVar?, usesOptionalType: Bool)? {
let semvar = SemVar(string: string) let loaded = try? await getVersionString(fileUrl: file, projectDirectory: projectDirectory)
return (semvar, usesOptionalType) 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( private func getVersionString(
fileUrl: URL, fileUrl: URL,
gitDirectory: String? projectDirectory: String?
) async throws -> (version: String, usesOptionalType: Bool) { ) async throws -> (version: String, usesOptionalType: Bool) {
@Dependency(\.gitClient) var gitClient @Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger @Dependency(\.logger) var logger

View File

@@ -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
}
}

View File

@@ -0,0 +1,172 @@
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
import LoggingExtensions
enum VersionContainer: Sendable {
case branch(CurrentVersionContainer<String>)
case semvar(CurrentVersionContainer<SemVar>)
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),
projectDirectory: projectDirectory,
url: url
))
case .semvar:
return try await .semvar(.load(
semvar: strategy.semvar!,
projectDirectory: projectDirectory,
url: url
))
}
}
}
// TODO: Add a precedence field for which version to prefer, should also be specified in
// configuration.
struct CurrentVersionContainer<Version> {
let targetUrl: URL
let usesOptionalType: Bool
let loadedVersion: Version?
let precedence: Configuration.SemVar.Precedence?
let strategyVersion: Version?
}
extension CurrentVersionContainer: Equatable where Version: Equatable {
var hasChanges: Bool {
switch (loadedVersion, strategyVersion) {
case (.none, .none):
return false
case (.some, .none),
(.none, .some):
return true
case let (.some(loaded), .some(next)):
return loaded == next
}
}
}
extension CurrentVersionContainer: Sendable where Version: Sendable {}
extension CurrentVersionContainer where Version == String {
static func load(
branch: Configuration.Branch,
projectDirectory: String?,
url: URL
) async throws -> Self {
@Dependency(\.fileClient) var fileClient
@Dependency(\.gitClient) var gitClient
let loaded = try await fileClient.branch(
file: url,
projectDirectory: projectDirectory,
requireExistingFile: false
)
let next = try await gitClient.version(.init(
gitDirectory: projectDirectory,
style: .branch(commitSha: branch.includeCommitSha)
))
return .init(
targetUrl: url,
usesOptionalType: loaded?.1 ?? true,
loadedVersion: loaded?.0,
precedence: nil,
strategyVersion: next.description
)
}
var versionString: String? {
loadedVersion ?? strategyVersion
}
}
extension CurrentVersionContainer where Version == SemVar {
// TODO: Update to use precedence and not fetch `nextVersion` if we loaded a file version.
static func load(semvar: Configuration.SemVar, projectDirectory: String?, url: URL) async throws -> Self {
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
logger.trace("Begin loading semvar from: \(url.cleanFilePath)")
async let (loaded, usesOptionalType) = try await loadCurrentVersion(
semvar: semvar,
projectDirectory: projectDirectory,
url: url
)
async let next = try await loadNextVersion(semvar: semvar, projectDirectory: projectDirectory)
return try await .init(
targetUrl: url,
usesOptionalType: usesOptionalType,
loadedVersion: loaded,
precedence: semvar.precedence,
strategyVersion: next
)
}
static func loadCurrentVersion(
semvar: Configuration.SemVar,
projectDirectory: 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,
projectDirectory: projectDirectory,
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)
}
let (loaded, usesOptionalType) = loadedStrong
logger.dump(loaded) { "Loaded version:\n\($0)" }
return (loaded, usesOptionalType)
}
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? {
let version: SemVar?
switch precedence ?? .default {
case .file:
version = loadedVersion ?? strategyVersion
case .strategy:
version = strategyVersion ?? loadedVersion
}
return version?.versionString(withPreReleaseTag: withPreRelease)
}
}

View File

@@ -1,6 +1,7 @@
import Dependencies import Dependencies
import FileClient import FileClient
import Foundation import Foundation
import LoggingExtensions
@_spi(Internal) @_spi(Internal)
public extension Configuration { public extension Configuration {
@@ -44,8 +45,12 @@ public extension Configuration.Branch {
@_spi(Internal) @_spi(Internal)
public extension Configuration.SemVar { public extension Configuration.SemVar {
func merging(_ other: Self?) -> Self { func merging(_ other: Self?) -> Self {
.init( @Dependency(\.logger) var logger
logger.dump(other, level: .trace) { "Merging semvar:\n\($0)" }
return .init(
allowPreRelease: other?.allowPreRelease ?? allowPreRelease, allowPreRelease: other?.allowPreRelease ?? allowPreRelease,
precedence: other?.precedence ?? precedence,
preRelease: preRelease == nil ? other?.preRelease : preRelease!.merging(other?.preRelease), preRelease: preRelease == nil ? other?.preRelease : preRelease!.merging(other?.preRelease),
requireExistingFile: other?.requireExistingFile ?? requireExistingFile, requireExistingFile: other?.requireExistingFile ?? requireExistingFile,
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar, requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar,

View File

@@ -145,6 +145,7 @@ public extension Configuration {
case semvar( case semvar(
allowPreRelease: Bool? = nil, allowPreRelease: Bool? = nil,
precedence: SemVar.Precedence? = nil,
preRelease: PreRelease? = nil, preRelease: PreRelease? = nil,
requireExistingFile: Bool? = nil, requireExistingFile: Bool? = nil,
requireExistingSemVar: Bool? = nil, requireExistingSemVar: Bool? = nil,
@@ -159,10 +160,16 @@ public extension Configuration {
} }
public var semvar: SemVar? { public var semvar: SemVar? {
guard case let .semvar(allowPreRelease, preRelease, requireExistingFile, requireExistingSemVar, strategy) = self guard case let .semvar(
allowPreRelease,
precedence,
preRelease,
requireExistingFile, requireExistingSemVar, strategy
) = self
else { return nil } else { return nil }
return .init( return .init(
allowPreRelease: allowPreRelease, allowPreRelease: allowPreRelease,
precedence: precedence,
preRelease: preRelease, preRelease: preRelease,
requireExistingFile: requireExistingFile ?? false, requireExistingFile: requireExistingFile ?? false,
requireExistingSemVar: requireExistingSemVar ?? false, requireExistingSemVar: requireExistingSemVar ?? false,
@@ -177,6 +184,7 @@ public extension Configuration {
public static func semvar(_ value: SemVar) -> Self { public static func semvar(_ value: SemVar) -> Self {
.semvar( .semvar(
allowPreRelease: value.allowPreRelease, allowPreRelease: value.allowPreRelease,
precedence: value.precedence,
preRelease: value.preRelease, preRelease: value.preRelease,
requireExistingFile: value.requireExistingFile, requireExistingFile: value.requireExistingFile,
requireExistingSemVar: value.requireExistingSemVar, requireExistingSemVar: value.requireExistingSemVar,
@@ -249,8 +257,17 @@ public extension Configuration {
/// ///
struct SemVar: Codable, Equatable, Sendable { struct SemVar: Codable, Equatable, Sendable {
/// Allow semvar to include a pre-release suffix.
public let allowPreRelease: Bool? public let allowPreRelease: Bool?
/// Set the precedence of version loaded from file versus
/// the version returned by the strategy.
///
/// These can not always agree / reflect the same version,
/// so the default is to give the file version precedence over
/// the strategy.
public let precedence: Precedence?
/// Optional pre-releas suffix strategy. /// Optional pre-releas suffix strategy.
public let preRelease: PreRelease? public let preRelease: PreRelease?
@@ -260,27 +277,53 @@ public extension Configuration {
/// Fail if an existing semvar is not parsed from the file or version generation strategy. /// Fail if an existing semvar is not parsed from the file or version generation strategy.
public let requireExistingSemVar: Bool? public let requireExistingSemVar: Bool?
/// The strategy used to derive a version for the project.
public let strategy: Strategy? public let strategy: Strategy?
public init( public init(
allowPreRelease: Bool? = true, allowPreRelease: Bool? = true,
precedence: Precedence? = nil,
preRelease: PreRelease? = nil, preRelease: PreRelease? = nil,
requireExistingFile: Bool? = false, requireExistingFile: Bool? = false,
requireExistingSemVar: Bool? = false, requireExistingSemVar: Bool? = false,
strategy: Strategy? = nil strategy: Strategy? = nil
) { ) {
self.allowPreRelease = allowPreRelease self.allowPreRelease = allowPreRelease
self.precedence = precedence
self.preRelease = preRelease self.preRelease = preRelease
self.requireExistingFile = requireExistingFile self.requireExistingFile = requireExistingFile
self.requireExistingSemVar = requireExistingSemVar self.requireExistingSemVar = requireExistingSemVar
self.strategy = strategy self.strategy = strategy
} }
/// Represents a strategy to derive a version for a project.
public enum Strategy: Codable, Equatable, Sendable { public enum Strategy: Codable, Equatable, Sendable {
/// A custom external command that should return a string that
/// can be parsed as a semvar.
case command(arguments: [String]) case command(arguments: [String])
/// Use `git describe --tags` optionally as an exact match.
case gitTag(exactMatch: Bool? = false) case gitTag(exactMatch: Bool? = false)
} }
/// Represents the precedence for which version to use when a file
/// exists, as they don't always agree. For example, a file could be edited
/// manually or the tag doesn't represent what is parsed from calling the
/// strategy.
///
/// The default is to defer to the file (if it exists) as having precedence over
/// the strategy.
public enum Precedence: String, CaseIterable, Codable, Equatable, Sendable {
/// Give the file precedence over the strategy.
case file
/// Give the strategy precedence over the file.
case strategy
/// The default precedence.
public static var `default`: Self { .file }
}
} }
} }

View File

@@ -1,4 +1,6 @@
import CustomDump
import Dependencies import Dependencies
import Logging
import ShellClient import ShellClient
public struct LoggingOptions: Equatable, Sendable { public struct LoggingOptions: Equatable, Sendable {
@@ -26,3 +28,15 @@ public struct LoggingOptions: Equatable, Sendable {
} }
} }
} }
public extension Logger {
func dump<T>(
_ type: T,
level: Level = .trace,
buildMessage: @escaping (String) -> String = { $0 }
) {
var message = ""
customDump(type, to: &message)
log(level: level, "\(buildMessage(message))")
}
}

View File

@@ -15,10 +15,13 @@ struct CliClientTests {
arguments: TestArguments.testCases arguments: TestArguments.testCases
) )
func testBuild(target: String) async throws { func testBuild(target: String) async throws {
let template = Template.build("1.0.0")
try await run { try await run {
$0.fileClient.fileExists = { _ in true } $0.fileClient.fileExists = { _ in true }
$0.fileClient.read = { @Sendable _ in template }
} operation: { } operation: {
@Dependency(\.cliClient) var client @Dependency(\.cliClient) var client
let output = try await client.build(.testOptions( let output = try await client.build(.testOptions(
target: target, target: target,
versionStrategy: .semvar(requireExistingFile: false) versionStrategy: .semvar(requireExistingFile: false)

1
gum Normal file
View File

@@ -0,0 +1 @@
foo,bar,baz table