Compare commits

8 Commits

Author SHA1 Message Date
47bad744d5 feat: Removes some old todo comments.
All checks were successful
CI / Ubuntu (push) Successful in 2m21s
Release / release (push) Successful in 11s
2024-12-26 14:04:37 -05:00
2650bdc670 feat: Better checks on if semvar has changes prior to writing to a file.
All checks were successful
CI / Ubuntu (push) Successful in 2m51s
2024-12-26 13:06:31 -05:00
a0f8611a76 feat: Working on command-line documentation.
All checks were successful
CI / Ubuntu (push) Successful in 2m52s
2024-12-26 12:13:42 -05:00
86a344fa9f feat: Removes old configuration merging file from cli-client.
All checks were successful
CI / Ubuntu (push) Successful in 2m42s
2024-12-26 07:58:01 -05:00
56359f3488 feat: Moves logging extensions into it's own module, moves configuration merging into config-client, fixes merging bug. 2024-12-25 20:06:52 -05:00
fbb4a22e98 feat: Updates configuration commands and parsing strategy.
All checks were successful
CI / Ubuntu (push) Successful in 2m59s
2024-12-25 17:31:38 -05:00
3cfd882f38 feat: Some variable renaming for consistency.
All checks were successful
CI / Ubuntu (push) Successful in 2m58s
2024-12-24 23:16:13 -05:00
a885e3dfa3 feat: Updates logging configuration. 2024-12-24 21:32:14 -05:00
28 changed files with 1279 additions and 339 deletions

View File

@@ -1,14 +1,11 @@
{ {
"target" : {
"module" : { "name" : "bump-version" }
},
"strategy" : { "strategy" : {
"semvar" : { "semvar" : {
"preRelease" : { "preRelease" : { "strategy": { "gitTag" : {} } },
"strategy" : { "gitTag" : {} } "strategy" : { "gitTag": { "exactMatch": false } }
}
}
},
"target" : {
"module" : {
"name" : "bump-version"
} }
} }
} }

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "6ab0a9c883cfa1490d249a344074ad27369033fab78e1a90272ef07339a8c0ab", "originHash" : "3640b52c8069b868611efbfbd9b7545872526454802225747f7cd878062df1db",
"pins" : [ "pins" : [
{ {
"identity" : "combine-schedulers", "identity" : "combine-schedulers",
@@ -28,6 +28,15 @@
"version" : "1.5.0" "version" : "1.5.0"
} }
}, },
{
"identity" : "swift-cli-doc",
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-cli-doc.git",
"state" : {
"revision" : "bbace73d974fd3e6985461431692bea773c7c5d8",
"version" : "0.2.1"
}
},
{ {
"identity" : "swift-clocks", "identity" : "swift-clocks",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -13,6 +13,7 @@ let package = Package(
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]), .library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
.library(name: "FileClient", targets: ["FileClient"]), .library(name: "FileClient", targets: ["FileClient"]),
.library(name: "GitClient", targets: ["GitClient"]), .library(name: "GitClient", targets: ["GitClient"]),
.library(name: "LoggingExtensions", targets: ["LoggingExtensions"]),
.plugin(name: "BuildWithVersionPlugin", targets: ["BuildWithVersionPlugin"]), .plugin(name: "BuildWithVersionPlugin", targets: ["BuildWithVersionPlugin"]),
.plugin(name: "GenerateVersionPlugin", targets: ["GenerateVersionPlugin"]), .plugin(name: "GenerateVersionPlugin", targets: ["GenerateVersionPlugin"]),
.plugin(name: "UpdateVersionPlugin", targets: ["UpdateVersionPlugin"]) .plugin(name: "UpdateVersionPlugin", targets: ["UpdateVersionPlugin"])
@@ -20,6 +21,7 @@ let package = Package(
dependencies: [ dependencies: [
.package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.2"), .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.2"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.2"), .package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.2"),
.package(url: "https://github.com/m-housh/swift-cli-doc.git", from: "0.2.1"),
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.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-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"),
@@ -31,7 +33,8 @@ let package = Package(
dependencies: [ dependencies: [
"CliClient", "CliClient",
.product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "CustomDump", package: "swift-custom-dump") .product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "CliDoc", package: "swift-cli-doc")
] ]
), ),
.target( .target(
@@ -40,7 +43,9 @@ let package = Package(
"ConfigurationClient", "ConfigurationClient",
"FileClient", "FileClient",
"GitClient", "GitClient",
.product(name: "Logging", package: "swift-log") "LoggingExtensions",
.product(name: "Logging", package: "swift-log"),
.product(name: "CustomDump", package: "swift-custom-dump")
] ]
), ),
.testTarget( .testTarget(
@@ -80,6 +85,13 @@ let package = Package(
name: "GitClientTests", name: "GitClientTests",
dependencies: ["GitClient"] dependencies: ["GitClient"]
), ),
.target(
name: "LoggingExtensions",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.target(name: "TestSupport"), .target(name: "TestSupport"),
.plugin( .plugin(
name: "BuildWithVersionPlugin", name: "BuildWithVersionPlugin",

View File

@@ -4,6 +4,7 @@ import DependenciesMacros
import FileClient import FileClient
import Foundation import Foundation
import GitClient import GitClient
import LoggingExtensions
import ShellClient import ShellClient
public extension DependencyValues { public extension DependencyValues {
@@ -39,7 +40,7 @@ public struct CliClient: Sendable {
let allowPreReleaseTag: Bool let allowPreReleaseTag: Bool
let dryRun: Bool let dryRun: Bool
let gitDirectory: String? let gitDirectory: String?
let logLevel: Logger.Level let loggingOptions: LoggingOptions
let target: Configuration.Target? let target: Configuration.Target?
let branch: Configuration.Branch? let branch: Configuration.Branch?
let semvar: Configuration.SemVar? let semvar: Configuration.SemVar?
@@ -49,7 +50,7 @@ public struct CliClient: Sendable {
allowPreReleaseTag: Bool = true, allowPreReleaseTag: Bool = true,
dryRun: Bool = false, dryRun: Bool = false,
gitDirectory: String? = nil, gitDirectory: String? = nil,
logLevel: Logger.Level = .debug, loggingOptions: LoggingOptions,
target: Configuration.Target? = nil, target: Configuration.Target? = nil,
branch: Configuration.Branch? = nil, branch: Configuration.Branch? = nil,
semvar: Configuration.SemVar? = nil, semvar: Configuration.SemVar? = nil,
@@ -58,34 +59,12 @@ public struct CliClient: Sendable {
self.allowPreReleaseTag = allowPreReleaseTag self.allowPreReleaseTag = allowPreReleaseTag
self.dryRun = dryRun self.dryRun = dryRun
self.gitDirectory = gitDirectory self.gitDirectory = gitDirectory
self.logLevel = logLevel self.loggingOptions = loggingOptions
self.target = target self.target = target
self.branch = branch self.branch = branch
self.semvar = semvar self.semvar = semvar
self.configurationFile = configurationFile self.configurationFile = configurationFile
} }
public init(
allowPreReleaseTag: Bool = true,
dryRun: Bool = false,
gitDirectory: String? = nil,
verbose: Int,
target: Configuration.Target? = nil,
branch: Configuration.Branch? = nil,
semvar: Configuration.SemVar? = nil,
configurationFile: String? = nil
) {
self.init(
allowPreReleaseTag: allowPreReleaseTag,
dryRun: dryRun,
gitDirectory: gitDirectory,
logLevel: .init(verbose: verbose),
target: target,
branch: branch,
semvar: semvar,
configurationFile: configurationFile
)
}
} }
} }
@@ -99,8 +78,10 @@ extension CliClient: DependencyKey {
bump: { try await $1.bump($0) }, bump: { try await $1.bump($0) },
generate: { try await $0.generate() }, generate: { try await $0.generate() },
parsedConfiguration: { options in parsedConfiguration: { options in
try await options.loggingOptions.withLogger {
try await options.withMergedConfiguration { $0 } try await options.withMergedConfiguration { $0 }
} }
}
) )
} }

View File

@@ -4,4 +4,5 @@ enum CliClientError: Error {
case fileDoesNotExist(path: String) case fileDoesNotExist(path: String)
case failedToParseVersionFile case failedToParseVersionFile
case semVarNotFound case semVarNotFound
case preReleaseParsingError(String)
} }

View File

@@ -1,4 +1,5 @@
import ConfigurationClient import ConfigurationClient
import CustomDump
import Dependencies import Dependencies
import FileClient import FileClient
import Foundation import Foundation
@@ -7,37 +8,21 @@ import GitClient
@_spi(Internal) @_spi(Internal)
public extension CliClient.SharedOptions { public extension CliClient.SharedOptions {
// Merges any configuration set via the passed in options. /// All cli-client calls should run through this, it set's up logging,
@discardableResult /// loads configuration, and generates the current version based on the
func withMergedConfiguration<T>( /// configuration.
operation: (Configuration) async throws -> T
) async throws -> T {
try await withConfiguration(path: configurationFile) { configuration in
var configuration = configuration
configuration = configuration.mergingTarget(target)
if configuration.strategy?.branch != nil, let branch {
configuration = configuration.mergingStrategy(.branch(branch))
} else if let semvar {
configuration = configuration.mergingStrategy(.semvar(semvar))
}
return try await operation(configuration)
}
}
@discardableResult @discardableResult
func run( func run(
_ operation: (CurrentVersionContainer) async throws -> Void _ operation: (CurrentVersionContainer) async throws -> Void
) async rethrows -> String { ) async rethrows -> String {
try await withDependencies { try await loggingOptions.withLogger {
$0.logger.logLevel = logLevel
} operation: {
// 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
logger.debug("Configuration: \(configuration)") var configurationString = ""
customDump(configuration, to: &configurationString)
logger.trace("\nConfiguration: \(configurationString)")
// 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: gitDirectory) let targetUrl = try configuration.targetUrl(gitDirectory: gitDirectory)
@@ -57,6 +42,39 @@ public extension CliClient.SharedOptions {
} }
} }
// Merges any configuration set via the passed in options.
@discardableResult
func withMergedConfiguration<T>(
operation: (Configuration) async throws -> T
) async throws -> T {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
var strategy: Configuration.VersionStrategy?
if let branch {
logger.trace("Merging branch strategy.")
strategy = .branch(branch)
} else if let semvar {
logger.trace("Merging semvar strategy.")
var semvarString = ""
customDump(semvar, to: &semvarString)
logger.trace("\(semvarString)")
strategy = .semvar(semvar)
}
let configuration = Configuration(
target: target,
strategy: strategy
)
return try await configurationClient.withConfiguration(
path: configurationFile,
merging: configuration,
operation: operation
)
}
func write(_ string: String, to url: URL) async throws { func write(_ string: String, to url: URL) async throws {
@Dependency(\.fileClient) var fileClient @Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger @Dependency(\.logger) var logger
@@ -64,7 +82,7 @@ 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.debug("\(string)") logger.info("\n\(string)\n")
} }
} }
@@ -90,20 +108,20 @@ public struct CurrentVersionContainer: Sendable {
var usesOptionalType: Bool { var usesOptionalType: Bool {
switch version { switch version {
case .string: return false case .string: return false
case let .semVar(_, usesOptionalType): return usesOptionalType case let .semvar(_, usesOptionalType, _): return usesOptionalType
} }
} }
public enum Version: Sendable { public enum Version: Sendable {
case string(String) case string(String)
case semVar(SemVar, usesOptionalType: Bool = true) case semvar(SemVar, usesOptionalType: Bool = true, hasChanges: Bool)
func string(allowPreReleaseTag: Bool) throws -> String { func string(allowPreReleaseTag: Bool) throws -> String {
switch self { switch self {
case let .string(string): case let .string(string):
return string return string
case let .semVar(semVar, usesOptionalType: _): case let .semvar(semvar, usesOptionalType: _, hasChanges: _):
return semVar.versionString(withPreReleaseTag: allowPreReleaseTag) return semvar.versionString(withPreReleaseTag: allowPreReleaseTag)
} }
} }
} }
@@ -127,14 +145,20 @@ extension CliClient.SharedOptions {
@Dependency(\.logger) var logger @Dependency(\.logger) var logger
switch container.version { switch container.version {
case .string: // When we did not parse a semVar, just write whatever we parsed for the current version. case .string: // 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): case let .semvar(semvar, usesOptionalType: usesOptionalType, hasChanges: hasChanges):
logger.debug("Semvar prior to bumping: \(semVar)") logger.debug("Semvar prior to bumping: \(semvar)")
let bumped = semVar.bump(type, preRelease: nil) // preRelease is already set on semVar. let bumped = semvar.bump(type)
let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag) let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag)
guard bumped != semvar || hasChanges else {
logger.debug("No change, skipping.")
return
}
logger.debug("Bumped version: \(version)") logger.debug("Bumped version: \(version)")
let template = usesOptionalType ? Template.optional(version) : Template.build(version) let template = usesOptionalType ? Template.optional(version) : Template.build(version)
try await write(template, to: container.targetUrl) try await write(template, to: container.targetUrl)

View File

@@ -1,70 +0,0 @@
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
extension Configuration {
func mergingTarget(_ otherTarget: Configuration.Target?) -> Self {
.init(
target: otherTarget ?? target,
strategy: strategy
)
}
func mergingStrategy(_ otherStrategy: Configuration.VersionStrategy?) -> Self {
.init(
target: target,
strategy: strategy?.merging(otherStrategy)
)
}
}
extension Configuration.Branch {
func merging(_ other: Self?) -> Self {
return .init(includeCommitSha: other?.includeCommitSha ?? includeCommitSha)
}
}
extension Configuration.SemVar {
func merging(_ other: Self?) -> Self {
.init(
preRelease: other?.preRelease ?? preRelease,
requireExistingFile: other?.requireExistingFile ?? requireExistingFile,
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar
)
}
}
extension Configuration.VersionStrategy {
func merging(_ other: Self?) -> Self {
guard let branch else {
guard let semvar else { return self }
return .semvar(semvar.merging(other?.semvar))
}
return .branch(branch.merging(other?.branch))
}
}
extension Configuration {
func merging(_ other: Self?) -> Self {
var output = self
output = output.mergingTarget(other?.target)
output = output.mergingStrategy(other?.strategy)
return output
}
}
@discardableResult
func withConfiguration<T>(
path: String?,
_ operation: (Configuration) async throws -> T
) async throws -> T {
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad(
path != nil ? URL(filePath: path!) : nil
)
return try await operation(configuration)
}

View File

@@ -2,6 +2,7 @@ import ConfigurationClient
import Dependencies import Dependencies
import Foundation import Foundation
import GitClient import GitClient
import ShellClient
extension Configuration { extension Configuration {
func targetUrl(gitDirectory: String?) throws -> URL { func targetUrl(gitDirectory: String?) throws -> URL {
@@ -58,26 +59,46 @@ extension Configuration.Target {
} }
extension GitClient { extension GitClient {
func version(branch: Configuration.Branch, gitDirectory: String?) async throws -> String { func version(includeCommitSha: Bool, gitDirectory: String?) async throws -> String {
@Dependency(\.gitClient) var gitClient @Dependency(\.gitClient) var gitClient
return try await gitClient.version(.init( return try await gitClient.version(.init(
gitDirectory: gitDirectory, gitDirectory: gitDirectory,
style: .branch(commitSha: branch.includeCommitSha) style: .branch(commitSha: includeCommitSha)
)).description )).description
} }
} }
extension Configuration.PreRelease { extension Configuration.PreRelease {
func preReleaseString(gitDirectory: String?) async throws -> String { // FIX: This needs to handle the pre-release type appropriatly.
func preReleaseString(gitDirectory: String?) async throws -> PreReleaseString? {
guard let strategy else { return nil }
@Dependency(\.asyncShellClient) var asyncShellClient
@Dependency(\.gitClient) var gitClient @Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger
let preReleaseString: String var preReleaseString: String
var suffix = true
var allowsPrefix = true
if let branch = strategy?.branch { switch strategy {
preReleaseString = try await gitClient.version(branch: branch, gitDirectory: gitDirectory) case let .branch(includeCommitSha: includeCommitSha):
} else { logger.trace("Branch pre-release strategy, includeCommitSha: \(includeCommitSha).")
preReleaseString = try await gitClient.version(
includeCommitSha: includeCommitSha,
gitDirectory: gitDirectory
)
case let .command(arguments: arguments):
logger.trace("Command pre-release strategy, arguments: \(arguments).")
// TODO: What to do with allows prefix? Need a configuration setting for commands.
preReleaseString = try await asyncShellClient.background(.init(arguments))
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( preReleaseString = try await gitClient.version(.init(
gitDirectory: gitDirectory, gitDirectory: gitDirectory,
style: .tag(exactMatch: false) style: .tag(exactMatch: false)
@@ -85,27 +106,53 @@ extension Configuration.PreRelease {
} }
if let prefix { if let prefix {
return "\(prefix)-\(preReleaseString)" if allowsPrefix {
preReleaseString = "\(prefix)-\(preReleaseString)"
} else {
logger.warning("Found prefix, but pre-release strategy may not work properly, ignoring prefix.")
} }
return preReleaseString }
guard suffix else { return .semvar(preReleaseString) }
return .suffix(preReleaseString)
// return preReleaseString
}
enum PreReleaseString: Sendable {
case suffix(String)
case semvar(String)
} }
} }
@_spi(Internal) @_spi(Internal)
public extension Configuration.SemVar { public extension Configuration.SemVar {
private func applyingPreRelease(_ semVar: SemVar, _ gitDirectory: String?) async throws -> SemVar { private func applyingPreRelease(_ semvar: SemVar, _ gitDirectory: String?) async throws -> SemVar {
@Dependency(\.logger) var logger @Dependency(\.logger) var logger
logger.trace("Start apply pre-release to: \(semVar)") logger.trace("Start apply pre-release to: \(semvar)")
guard let preReleaseStrategy = self.preRelease else {
guard let preReleaseStrategy = self.preRelease,
let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory)
else {
logger.trace("No pre-release strategy, returning original semvar.") logger.trace("No pre-release strategy, returning original semvar.")
return semVar return semvar
} }
let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory) // let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory)
logger.trace("Pre-release string: \(preRelease)") logger.trace("Pre-release string: \(preRelease)")
return semVar.applyingPreRelease(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)
}
return semvar
}
// return semVar.applyingPreRelease(preRelease)
} }
func currentVersion(file: URL, gitDirectory: String? = nil) async throws -> CurrentVersionContainer.Version { func currentVersion(file: URL, gitDirectory: String? = nil) async throws -> CurrentVersionContainer.Version {
@@ -113,7 +160,7 @@ public extension Configuration.SemVar {
@Dependency(\.gitClient) var gitClient @Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger @Dependency(\.logger) var logger
let fileOutput = try? await fileClient.semVar(file: file, gitDirectory: gitDirectory) let fileOutput = try? await fileClient.semvar(file: file, gitDirectory: gitDirectory)
var semVar = fileOutput?.semVar var semVar = fileOutput?.semVar
logger.trace("file output semvar: \(String(describing: semVar))") logger.trace("file output semvar: \(String(describing: semVar))")
@@ -122,13 +169,16 @@ public extension Configuration.SemVar {
// We parsed a semvar from the existing file, use it. // We parsed a semvar from the existing file, use it.
if semVar != nil { if semVar != nil {
return try await .semVar( let semvarWithPreRelease = try await applyingPreRelease(semVar!, gitDirectory)
applyingPreRelease(semVar!, gitDirectory),
usesOptionalType: usesOptionalType ?? false return .semvar(
semvarWithPreRelease,
usesOptionalType: usesOptionalType ?? false,
hasChanges: semvarWithPreRelease != semVar
) )
} }
if requireExistingFile { if requireExistingFile == true {
logger.debug("Failed to parse existing file, and caller requires it.") logger.debug("Failed to parse existing file, and caller requires it.")
throw CliClientError.fileDoesNotExist(path: file.cleanFilePath) throw CliClientError.fileDoesNotExist(path: file.cleanFilePath)
} }
@@ -142,22 +192,25 @@ public extension Configuration.SemVar {
)).semVar )).semVar
if semVar != nil { if semVar != nil {
return try await .semVar( let semvarWithPreRelease = try await applyingPreRelease(semVar!, gitDirectory)
applyingPreRelease(semVar!, gitDirectory), return .semvar(
usesOptionalType: usesOptionalType ?? false semvarWithPreRelease,
usesOptionalType: usesOptionalType ?? false,
hasChanges: semvarWithPreRelease != semVar
) )
} }
if requireExistingSemVar { if requireExistingSemVar == true {
logger.trace("Caller requires existing semvar and it was not found in file or git-tag.") logger.trace("Caller requires existing semvar and it was not found in file or git-tag.")
throw CliClientError.semVarNotFound throw CliClientError.semVarNotFound
} }
// Semvar doesn't exist, so create a new one. // Semvar doesn't exist, so create a new one.
logger.trace("Generating new semvar.") logger.trace("Generating new semvar.")
return try await .semVar( return try await .semvar(
applyingPreRelease(.init(), gitDirectory), applyingPreRelease(.init(), gitDirectory),
usesOptionalType: usesOptionalType ?? false usesOptionalType: usesOptionalType ?? false,
hasChanges: true
) )
} }
} }
@@ -181,7 +234,7 @@ extension Configuration.VersionStrategy {
return try await .init( return try await .init(
targetUrl: targetUrl, targetUrl: targetUrl,
version: .string( version: .string(
gitClient.version(branch: branch, gitDirectory: gitDirectory) gitClient.version(includeCommitSha: branch.includeCommitSha, gitDirectory: gitDirectory)
) )
) )
} }

View File

@@ -28,17 +28,17 @@ public extension FileClient {
logger.debug("Version line: \(versionLine)") logger.debug("Version line: \(versionLine)")
let isOptional = versionLine.contains("String?") let isOptional = versionLine.contains("String?")
logger.debug("Uses optional: \(isOptional)") logger.trace("Uses optional: \(isOptional)")
let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last
guard let versionString else { guard let versionString else {
throw CliClientError.failedToParseVersionFile throw CliClientError.failedToParseVersionFile
} }
logger.debug("Parsed version string: \(versionString)") logger.trace("Parsed version string: \(versionString)")
return (String(versionString), isOptional) return (String(versionString), isOptional)
} }
func semVar( func semvar(
file: URL, file: URL,
gitDirectory: String? gitDirectory: String?
) async throws -> (semVar: SemVar?, usesOptionalType: Bool) { ) async throws -> (semVar: SemVar?, usesOptionalType: Bool) {

View File

@@ -90,7 +90,7 @@ public struct SemVar: CustomStringConvertible, Equatable, Sendable {
} }
// Bumps the sem-var by the given option (major, minor, patch) // Bumps the sem-var by the given option (major, minor, patch)
public func bump(_ option: CliClient.BumpOption, preRelease: String?) -> Self { public func bump(_ option: CliClient.BumpOption, preRelease: String? = nil) -> Self {
switch option { switch option {
case .major: case .major:
return .init( return .init(

View File

@@ -13,6 +13,7 @@ public struct Template: Sendable {
return """ return """
// Do not set this variable, it is set during the build process. // Do not set this variable, it is set during the build process.
let VERSION: \(type.rawValue) = \(versionString) let VERSION: \(type.rawValue) = \(versionString)
""" """
} }

View File

@@ -0,0 +1,71 @@
import Dependencies
import FileClient
import Foundation
@_spi(Internal)
public extension Configuration {
func merging(_ other: Self?) -> Self {
mergingTarget(other?.target).mergingStrategy(other?.strategy)
}
private func mergingTarget(_ otherTarget: Configuration.Target?) -> Self {
.init(
target: otherTarget ?? target,
strategy: strategy
)
}
private func mergingStrategy(_ otherStrategy: Configuration.VersionStrategy?) -> Self {
.init(
target: target,
strategy: strategy?.merging(otherStrategy)
)
}
}
@_spi(Internal)
public extension Configuration.PreRelease {
func merging(_ other: Self?) -> Self {
return .init(
prefix: other?.prefix ?? prefix,
strategy: other?.strategy ?? strategy
)
}
}
@_spi(Internal)
public extension Configuration.Branch {
func merging(_ other: Self?) -> Self {
return .init(includeCommitSha: other?.includeCommitSha ?? includeCommitSha)
}
}
@_spi(Internal)
public extension Configuration.SemVar {
func merging(_ other: Self?) -> Self {
.init(
allowPreRelease: other?.allowPreRelease ?? allowPreRelease,
preRelease: preRelease == nil ? other?.preRelease : preRelease!.merging(other?.preRelease),
requireExistingFile: other?.requireExistingFile ?? requireExistingFile,
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar,
strategy: other?.strategy ?? strategy
)
}
}
@_spi(Internal)
public extension Configuration.VersionStrategy {
func merging(_ other: Self?) -> Self {
guard let other else { return self }
switch other {
case .branch:
guard let branch else { return other }
return .branch(branch.merging(other.branch))
case .semvar:
guard let semvar else { return other }
return .semvar(semvar.merging(other.semvar))
}
}
}

View File

@@ -1,6 +1,8 @@
import CustomDump import CustomDump
import Foundation import Foundation
// TODO: Add pre-release strategy that just bumps an integer.
/// Represents configuration that can be set via a file, generally in the root of the /// Represents configuration that can be set via a file, generally in the root of the
/// project directory. /// project directory.
/// ///
@@ -26,7 +28,7 @@ public struct Configuration: Codable, Equatable, Sendable {
public static func mock(module: String = "cli-version") -> Self { public static func mock(module: String = "cli-version") -> Self {
.init( .init(
target: .init(module: .init(module)), target: .init(module: .init(module)),
strategy: .semvar(.init()) strategy: .semvar(.init(strategy: .gitTag(exactMatch: false)))
) )
} }
@@ -68,6 +70,7 @@ public extension Configuration {
struct PreRelease: Codable, Equatable, Sendable { struct PreRelease: Codable, Equatable, Sendable {
public let prefix: String? public let prefix: String?
// TODO: Remove optional.
public let strategy: Strategy? public let strategy: Strategy?
public init( public init(
@@ -81,6 +84,7 @@ public extension Configuration {
public enum Strategy: Codable, Equatable, Sendable { public enum Strategy: Codable, Equatable, Sendable {
case branch(includeCommitSha: Bool = true) case branch(includeCommitSha: Bool = true)
case command(arguments: [String]) case command(arguments: [String])
// TODO: Remove.
case gitTag case gitTag
public var branch: Branch? { public var branch: Branch? {
@@ -97,23 +101,36 @@ public extension Configuration {
/// ///
struct SemVar: Codable, Equatable, Sendable { struct SemVar: Codable, Equatable, Sendable {
public let allowPreRelease: Bool?
/// Optional pre-releas suffix strategy. /// Optional pre-releas suffix strategy.
public let preRelease: PreRelease? public let preRelease: PreRelease?
/// Fail if an existing version file does not exist in the target. /// Fail if an existing version file does not exist in the target.
public let requireExistingFile: Bool public let requireExistingFile: Bool?
/// 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?
public let strategy: Strategy?
public init( public init(
allowPreRelease: Bool? = true,
preRelease: PreRelease? = nil, preRelease: PreRelease? = nil,
requireExistingFile: Bool = true, requireExistingFile: Bool? = true,
requireExistingSemVar: Bool = true requireExistingSemVar: Bool? = true,
strategy: Strategy? = nil
) { ) {
self.allowPreRelease = allowPreRelease
self.preRelease = preRelease self.preRelease = preRelease
self.requireExistingFile = requireExistingFile self.requireExistingFile = requireExistingFile
self.requireExistingSemVar = requireExistingSemVar self.requireExistingSemVar = requireExistingSemVar
self.strategy = strategy
}
public enum Strategy: Codable, Equatable, Sendable {
case command(arguments: [String])
case gitTag(exactMatch: Bool? = false)
} }
} }
@@ -165,7 +182,7 @@ public extension Configuration {
/// - fileName: The file name located in the module directory. /// - fileName: The file name located in the module directory.
public init( public init(
_ name: String, _ name: String,
fileName: String? = "Version.swift" fileName: String? = nil
) { ) {
self.name = name self.name = name
self.fileName = fileName self.fileName = fileName
@@ -218,9 +235,11 @@ public extension Configuration {
case branch(includeCommitSha: Bool = true) case branch(includeCommitSha: Bool = true)
case semvar( case semvar(
allowPreRelease: Bool? = nil,
preRelease: PreRelease? = nil, preRelease: PreRelease? = nil,
requireExistingFile: Bool? = nil, requireExistingFile: Bool? = nil,
requireExistingSemVar: Bool? = nil requireExistingSemVar: Bool? = nil,
strategy: SemVar.Strategy? = nil
) )
public var branch: Branch? { public var branch: Branch? {
@@ -231,12 +250,14 @@ public extension Configuration {
} }
public var semvar: SemVar? { public var semvar: SemVar? {
guard case let .semvar(preRelease, requireExistingFile, requireExistingSemVar) = self guard case let .semvar(allowPreRelease, preRelease, requireExistingFile, requireExistingSemVar, strategy) = self
else { return nil } else { return nil }
return .init( return .init(
allowPreRelease: allowPreRelease,
preRelease: preRelease, preRelease: preRelease,
requireExistingFile: requireExistingFile ?? false, requireExistingFile: requireExistingFile ?? false,
requireExistingSemVar: requireExistingSemVar ?? false requireExistingSemVar: requireExistingSemVar ?? false,
strategy: strategy
) )
} }
@@ -246,9 +267,11 @@ public extension Configuration {
public static func semvar(_ value: SemVar) -> Self { public static func semvar(_ value: SemVar) -> Self {
.semvar( .semvar(
allowPreRelease: value.allowPreRelease,
preRelease: value.preRelease, preRelease: value.preRelease,
requireExistingFile: value.requireExistingFile, requireExistingFile: value.requireExistingFile,
requireExistingSemVar: value.requireExistingSemVar requireExistingSemVar: value.requireExistingSemVar,
strategy: value.strategy
) )
} }

View File

@@ -16,6 +16,9 @@ public extension DependencyValues {
@DependencyClient @DependencyClient
public struct ConfigurationClient: Sendable { public struct ConfigurationClient: Sendable {
/// The default file name for a configuration file.
public var defaultFileName: @Sendable () -> String = { "test.json" }
/// Find a configuration file in the given directory or in current working directory. /// Find a configuration file in the given directory or in current working directory.
public var find: @Sendable (URL?) async throws -> URL? public var find: @Sendable (URL?) async throws -> URL?
@@ -32,6 +35,25 @@ public struct ConfigurationClient: Sendable {
} }
return (try? await load(url)) ?? .default return (try? await load(url)) ?? .default
} }
/// Loads configuration from the given path, or searches for the default file and loads it.
/// Optionally merges other configuration, then perform an operation with the loaded configuration.
///
/// - Parameters:
/// - path: Optional file path of the configuration to load.
/// - other: Optional configuration to merge with the loaded configuration.
/// - operation: The operation to perform with the loaded configuration.
@discardableResult
public func withConfiguration<T>(
path: String?,
merging other: Configuration? = nil,
operation: (Configuration) async throws -> T
) async throws -> T {
let configuration = try await findAndLoad(
path != nil ? URL(filePath: path!) : nil
)
return try await operation(configuration.merging(other))
}
} }
extension ConfigurationClient: DependencyKey { extension ConfigurationClient: DependencyKey {
@@ -39,6 +61,7 @@ extension ConfigurationClient: DependencyKey {
public static var liveValue: ConfigurationClient { public static var liveValue: ConfigurationClient {
.init( .init(
defaultFileName: { "\(Constants.defaultFileNameWithoutExtension).json" },
find: { try await findConfiguration($0) }, find: { try await findConfiguration($0) },
load: { try await loadConfiguration($0) }, load: { try await loadConfiguration($0) },
write: { try await writeConfiguration($0, to: $1) } write: { try await writeConfiguration($0, to: $1) }

View File

@@ -0,0 +1,156 @@
import Dependencies
import Foundation
import Logging
import LoggingFormatAndPipe
import Rainbow
import ShellClient
// MARK: Custom colors.
extension String {
var orange: Self {
bit24(255, 165, 0)
}
var magenta: Self {
bit24(238, 130, 238)
}
}
extension Logger.Level {
var coloredString: String {
switch self {
case .info:
return "\(self)".cyan
case .warning:
return "\(self)".orange.bold
case .debug:
return "\(self)".green
case .trace:
return "\(self)".yellow
case .error:
return "\(self)".red.bold
default:
return "\(self)"
}
}
}
private struct LevelFormatter: LoggingFormatAndPipe.Formatter {
let basic: BasicFormatter
var timestampFormatter: DateFormatter { basic.timestampFormatter }
// swiftlint:disable function_parameter_count
func processLog(
level: Logger.Level,
message: Logger.Message,
prettyMetadata: String?,
file: String,
function: String,
line: UInt
) -> String {
let now = Date()
return basic.format.map { component -> String in
return processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}
.filter { $0.count > 0 }
.joined(separator: basic.separator ?? "")
}
public func processComponent(
_ component: LogComponent,
now: Date,
level: Logger.Level,
message: Logger.Message,
prettyMetadata: String?,
file: String,
function: String,
line: UInt
) -> String {
switch component {
case .level:
let maxLen = "\(Logger.Level.warning)".count
let paddingCount = (maxLen - "\(level)".count) / 2
var padding = ""
for _ in 0 ... paddingCount {
padding += " "
}
return "\(padding)\(level.coloredString)\(padding)"
case let .group(components):
return components.map { component -> String in
self.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}.joined()
case .message:
return basic.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
).italic
default:
return basic.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}
}
// swiftlint:enable function_parameter_count
}
extension LoggingOptions {
func makeLogger() -> Logger {
let formatters: [LogComponent] = [
.text(executableName.magenta),
.text(command.blue),
.level,
.group([
.text("-> "),
.message
])
]
return Logger(label: executableName) { _ in
LoggingFormatAndPipe.Handler(
formatter: LevelFormatter(basic: BasicFormatter(
formatters,
separator: " | "
)),
pipe: LoggerTextOutputStreamPipe.standardOutput
)
}
}
}

View File

@@ -1,14 +1,12 @@
import Logging import Logging
// TODO: Move.
@_spi(Internal) @_spi(Internal)
public extension Logger.Level { public extension Logger.Level {
init(verbose: Int) { init(verbose: Int) {
switch verbose { switch verbose {
case 1: self = .warning case 1: self = .debug
case 2: self = .debug case 2...: self = .trace
case 3...: self = .trace
default: self = .info default: self = .info
} }
} }

View File

@@ -0,0 +1,28 @@
import Dependencies
import ShellClient
public struct LoggingOptions: Equatable, Sendable {
let command: String
let executableName: String
let verbose: Int
public init(
executableName: String = "bump-version",
command: String,
verbose: Int
) {
self.executableName = executableName
self.command = command
self.verbose = verbose
}
public func withLogger<T>(_ operation: () async throws -> T) async rethrows -> T {
try await withDependencies {
$0.logger = makeLogger()
$0.logger.logLevel = .init(verbose: verbose)
} operation: {
try await operation()
}
}
}

View File

@@ -3,14 +3,16 @@ import Foundation
@main @main
struct Application: AsyncParsableCommand { struct Application: AsyncParsableCommand {
static let commandName = "bump-version"
static let configuration: CommandConfiguration = .init( static let configuration: CommandConfiguration = .init(
commandName: "bump-version", commandName: commandName,
version: VERSION ?? "0.0.0", version: VERSION ?? "0.0.0",
subcommands: [ subcommands: [
BuildCommand.self, BuildCommand.self,
BumpCommand.self, BumpCommand.self,
GenerateCommand.self, GenerateCommand.self,
UtilsCommand.self ConfigCommand.self
], ],
defaultSubcommand: BumpCommand.self defaultSubcommand: BumpCommand.self
) )

View File

@@ -1,19 +1,25 @@
import ArgumentParser import ArgumentParser
import CliClient import CliClient
import CliDoc
import Foundation import Foundation
import ShellClient import ShellClient
// NOTE: This command is only used with the build with version plugin.
struct BuildCommand: AsyncParsableCommand { struct BuildCommand: AsyncParsableCommand {
static let commandName = "build"
static let configuration: CommandConfiguration = .init( static let configuration: CommandConfiguration = .init(
commandName: "build", commandName: Self.commandName,
abstract: "Used for the build with version plugin.", abstract: Abstract.default("Used for the build with version plugin.").render(),
discussion: "This should generally not be interacted with directly, outside of the build plugin.", discussion: Discussion {
"This should generally not be interacted with directly, outside of the build plugin."
},
shouldDisplay: false shouldDisplay: false
) )
@OptionGroup var globals: GlobalOptions @OptionGroup var globals: GlobalOptions
func run() async throws { func run() async throws {
try await globals.run(\.build) try await globals.run(\.build, command: Self.commandName)
} }
} }

View File

@@ -1,12 +1,28 @@
import ArgumentParser import ArgumentParser
import CliClient import CliClient
import CliDoc
import Dependencies import Dependencies
struct BumpCommand: AsyncParsableCommand { struct BumpCommand: CommandRepresentable {
static let commandName = "bump"
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "bump", commandName: Self.commandName,
abstract: "Bump version of a command-line tool." abstract: Abstract.default("Bump version of a command-line tool."),
usage: Usage.default(commandName: nil),
discussion: Discussion.default(examples: [
makeExample(
label: "Basic usage, bump the minor version.",
example: "--minor",
includesAppName: false
),
makeExample(
label: "Dry run, just show what the bumped version would be.",
example: "--minor --dry-run",
includesAppName: false
)
])
) )
@OptionGroup var globals: GlobalOptions @OptionGroup var globals: GlobalOptions
@@ -19,7 +35,7 @@ struct BumpCommand: AsyncParsableCommand {
var bumpOption: CliClient.BumpOption = .patch var bumpOption: CliClient.BumpOption = .patch
func run() async throws { func run() async throws {
try await globals.run(\.bump, args: bumpOption) try await globals.run(\.bump, command: Self.commandName, args: bumpOption)
} }
} }

View File

@@ -0,0 +1,232 @@
import ArgumentParser
import CliClient
import CliDoc
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
struct ConfigCommand: AsyncParsableCommand {
static let commandName = "config"
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: Abstract.default("Configuration commands").render(),
subcommands: [
DumpConfig.self,
GenerateConfig.self
]
)
}
extension ConfigCommand {
struct DumpConfig: CommandRepresentable {
static let commandName = "dump"
static let parentCommand = ConfigCommand.commandName
static let configuration = CommandConfiguration(
commandName: Self.commandName,
abstract: Abstract.default("Inspect the parsed configuration."),
usage: Usage.default(parentCommand: ConfigCommand.commandName, commandName: Self.commandName),
discussion: Discussion.default(
notes: [
"""
The default style is to print the output in `json`, however you can use the `--swift` flag to
print the output in `swift`.
"""
],
examples: [
makeExample(label: "Show the project configuration.", example: ""),
makeExample(
label: "Update a configuration file with the dumped output",
example: "--disable-pre-release > .bump-version.prod.json"
)
]
) {
"""
Loads the project configuration file (if applicable) and merges the options passed in,
then prints the configuration to stdout.
"""
},
aliases: ["d"]
)
@Flag(
help: "Change the style of what get's printed."
)
fileprivate var printStyle: PrintStyle = .json
@OptionGroup var globals: ConfigCommandOptions
func run() async throws {
let configuration = try await globals
.shared(command: Self.commandName)
.runClient(\.parsedConfiguration)
try globals.printConfiguration(configuration, style: printStyle)
}
}
struct GenerateConfig: CommandRepresentable {
static let commandName = "generate"
static let parentCommand = ConfigCommand.commandName
static let configuration: CommandConfiguration = .init(
commandName: commandName,
abstract: Abstract.default("Generate a configuration file, based on the given options.").render(),
usage: Usage.default(parentCommand: ConfigCommand.commandName, commandName: commandName),
discussion: Discussion.default(examples: [
makeExample(
label: "Generate a configuration file for the 'foo' target.",
example: "-m foo"
),
makeExample(
label: "Show the output and don't write to a file.",
example: "-m foo --print"
)
]),
aliases: ["g"]
)
@Flag(
help: "The style of the configuration."
)
var style: ConfigCommand.Style = .semvar
@Flag(
name: .customLong("print"),
help: "Print json to stdout."
)
var printJson: Bool = false
@OptionGroup var globals: ConfigCommandOptions
func run() async throws {
try await withSetupDependencies {
@Dependency(\.configurationClient) var configurationClient
let configuration = try style.parseConfiguration(
configOptions: globals.configOptions,
extraOptions: globals.extraOptions
)
switch printJson {
case true:
try globals.handlePrintJson(configuration)
case false:
let url = globals.configFileUrl
try await configurationClient.write(configuration, url)
print(url.cleanFilePath)
}
}
}
}
}
extension ConfigCommand {
enum Style: EnumerableFlag {
case branch, semvar
func parseConfiguration(
configOptions: ConfigurationOptions,
extraOptions: [String]
) throws -> Configuration {
let strategy: Configuration.VersionStrategy
switch self {
case .branch:
strategy = .branch(includeCommitSha: configOptions.commitSha)
case .semvar:
strategy = try .semvar(configOptions.semvarOptions(extraOptions: extraOptions))
}
return try Configuration(
target: configOptions.target(),
strategy: strategy
)
}
}
// TODO: Add verbose.
@dynamicMemberLookup
struct ConfigCommandOptions: ParsableArguments {
@OptionGroup var configOptions: ConfigurationOptions
@Flag(
name: .shortAndLong,
help: "Increase logging level, can be passed multiple times (example: -vvv)."
)
var verbose: Int
@Argument(
help: """
Arguments / options used for custom pre-release, options / flags must proceed a '--' in
the command. These are ignored if the `--custom-command` or `--custom-pre-release` flag is not set.
"""
)
var extraOptions: [String] = []
subscript<T>(dynamicMember keyPath: KeyPath<ConfigurationOptions, T>) -> T {
configOptions[keyPath: keyPath]
}
}
}
private extension ConfigCommand.DumpConfig {
enum PrintStyle: EnumerableFlag {
case json, swift
}
}
private extension ConfigCommand.ConfigCommandOptions {
func shared(command: String) throws -> CliClient.SharedOptions {
try configOptions.shared(command: command, extraOptions: extraOptions, verbose: verbose)
}
func handlePrintJson(_ configuration: Configuration) throws {
@Dependency(\.coders) var coders
@Dependency(\.logger) var logger
let data = try coders.jsonEncoder().encode(configuration)
guard let string = String(bytes: data, encoding: .utf8) else {
logger.error("Error encoding configuration to json.")
throw ConfigurationEncodingError()
}
print(string)
}
func printConfiguration(
_ configuration: Configuration,
style: ConfigCommand.DumpConfig.PrintStyle
) throws {
switch style {
case .json:
try handlePrintJson(configuration)
case .swift:
customDump(configuration)
}
// guard printJson else {
// customDump(configuration)
// return
// }
// try handlePrintJson(configuration)
}
}
private extension ConfigurationOptions {
var configFileUrl: URL {
switch configurationFile {
case let .some(path):
return URL(filePath: path)
case .none:
return URL(filePath: ".bump-version.json")
}
}
}
struct ConfigurationEncodingError: Error {}

View File

@@ -1,19 +1,29 @@
import ArgumentParser import ArgumentParser
import CliClient import CliClient
import CliDoc
import Dependencies import Dependencies
import Foundation import Foundation
import ShellClient import ShellClient
struct GenerateCommand: AsyncParsableCommand { struct GenerateCommand: CommandRepresentable {
static let commandName = "generate"
static let configuration: CommandConfiguration = .init( static let configuration: CommandConfiguration = .init(
commandName: "generate", commandName: Self.commandName,
abstract: "Generates a version file in a command line tool that can be set via the git tag or git sha.", abstract: Abstract.default("Generates a version file in your project."),
discussion: "This command can be interacted with directly, outside of the plugin usage context." usage: Usage.default(commandName: Self.commandName),
discussion: Discussion.default(
examples: [
makeExample(label: "Basic usage.", example: "")
]
) {
"This command can be interacted with directly, outside of the plugin usage context."
}
) )
@OptionGroup var globals: GlobalOptions @OptionGroup var globals: GlobalOptions
func run() async throws { func run() async throws {
try await globals.run(\.generate) try await globals.run(\.generate, command: Self.commandName)
} }
} }

View File

@@ -1,95 +0,0 @@
import ArgumentParser
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
struct UtilsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "utils",
abstract: "Utility commands",
subcommands: [
DumpConfig.self,
GenerateConfig.self
]
)
}
extension UtilsCommand {
struct DumpConfig: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "dump-config",
abstract: "Show the parsed configuration.",
aliases: ["dc"]
)
@OptionGroup var globals: GlobalOptions
func run() async throws {
let configuration = try await globals.runClient(\.parsedConfiguration)
customDump(configuration)
}
}
struct GenerateConfig: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "generate-config",
abstract: "Generate a configuration file.",
aliases: ["gc"]
)
@OptionGroup var configOptions: ConfigurationOptions
@Flag(
help: "The style of the configuration."
)
var style: Style = .semvar
@Argument(
help: """
Arguments / options used for custom pre-release, options / flags must proceed a '--' in
the command. These are ignored if the `--custom` flag is not set.
"""
)
var extraOptions: [String] = []
func run() async throws {
try await withSetupDependencies {
@Dependency(\.configurationClient) var configurationClient
let strategy: Configuration.VersionStrategy
switch style {
case .branch:
strategy = .branch(includeCommitSha: configOptions.commitSha)
case .semvar:
strategy = try .semvar(configOptions.semvarOptions(extraOptions: extraOptions))
}
let configuration = try Configuration(
target: configOptions.target(),
strategy: strategy
)
let url: URL
switch configOptions.configurationFile {
case let .some(path):
url = URL(filePath: path)
case .none:
url = URL(filePath: ".bump-version.json")
}
try await configurationClient.write(configuration, url)
print(url.cleanFilePath)
}
}
}
}
extension UtilsCommand.GenerateConfig {
enum Style: EnumerableFlag {
case branch, semvar
}
}

View File

@@ -5,6 +5,8 @@ import Dependencies
import Foundation import Foundation
import Rainbow import Rainbow
// TODO: Add an option to not load project configuration.
struct GlobalOptions: ParsableArguments { struct GlobalOptions: ParsableArguments {
@OptionGroup @OptionGroup
@@ -31,7 +33,7 @@ struct GlobalOptions: ParsableArguments {
@Argument( @Argument(
help: """ help: """
Arguments / options used for custom pre-release, options / flags must proceed a '--' in Arguments / options used for custom pre-release, options / flags must proceed a '--' in
the command. These are ignored if the `--custom` flag is not set. the command. These are ignored if the `--custom` or `--custom-pre-release` flag is not set.
""" """
) )
var extraOptions: [String] = [] var extraOptions: [String] = []
@@ -40,8 +42,8 @@ struct GlobalOptions: ParsableArguments {
struct ConfigurationOptions: ParsableArguments { struct ConfigurationOptions: ParsableArguments {
@Option( @Option(
name: .shortAndLong, name: [.customShort("f"), .long],
help: "Specify the path to a configuration file.", help: "Specify the path to a configuration file. (default: .bump-version.json)",
completion: .file(extensions: ["json"]) completion: .file(extensions: ["json"])
) )
var configurationFile: String? var configurationFile: String?
@@ -63,22 +65,22 @@ struct ConfigurationOptions: ParsableArguments {
struct TargetOptions: ParsableArguments { struct TargetOptions: ParsableArguments {
@Option( @Option(
name: .shortAndLong, name: [.customShort("p"), .long],
help: "Path to the version file, not required if module is set." help: "Path to the version file, not required if module is set."
) )
var path: String? var targetFilePath: String?
@Option( @Option(
name: .shortAndLong, name: [.customShort("m"), .long],
help: "The target module name or directory path, not required if path is set." help: "The target module name or directory path, not required if path is set."
) )
var module: String? var targetModule: String?
@Option( @Option(
name: [.customShort("n"), .long], name: [.customShort("n"), .long],
help: "The file name inside the target module, required if module is set." help: "The file name inside the target module. (defaults to: \"Version.swift\")."
) )
var fileName: String = "Version.swift" var targetFileName: String?
} }
@@ -91,7 +93,7 @@ struct PreReleaseOptions: ParsableArguments {
var disablePreRelease: Bool = false var disablePreRelease: Bool = false
@Flag( @Flag(
name: [.customShort("s"), .customLong("pre-release-branch-style")], name: [.customShort("b"), .customLong("pre-release-branch-style")],
help: """ help: """
Use branch name and commit sha for pre-release suffix, ignored if branch is set. Use branch name and commit sha for pre-release suffix, ignored if branch is set.
""" """
@@ -124,8 +126,22 @@ struct PreReleaseOptions: ParsableArguments {
} }
// TODO: Add custom command strategy.
struct SemVarOptions: ParsableArguments { struct SemVarOptions: ParsableArguments {
@Flag(
name: .long,
inversion: .prefixedEnableDisable,
help: "Use git-tag strategy for semvar."
)
var gitTag: Bool = true
@Flag(
name: .long,
help: "Require exact match for git tag strategy."
)
var requireExactMatch: Bool = false
@Flag( @Flag(
name: .long, name: .long,
help: """ help: """
@@ -136,9 +152,18 @@ struct SemVarOptions: ParsableArguments {
@Flag( @Flag(
name: .long, name: .long,
help: "Fail if a sem-var is not parsed from existing file or git tag, used if branch is not set." help: "Fail if a semvar is not parsed from existing file or git tag, used if branch is not set."
) )
var requireExistingSemvar: Bool = false var requireExistingSemvar: Bool = false
@Flag(
name: .shortAndLong,
help: """
Custom command strategy, uses extra-options to call an external command.
The external command should return a semvar that is used.
"""
)
var customCommand: Bool = false
@OptionGroup var preRelease: PreReleaseOptions @OptionGroup var preRelease: PreReleaseOptions
} }

View File

@@ -0,0 +1,311 @@
/*
This file contains helpers for generating the documentation for the commands.
*/
import ArgumentParser
import CliDoc
import Rainbow
protocol CommandRepresentable: AsyncParsableCommand {
static var commandName: String { get }
static var parentCommand: String? { get }
}
extension CommandRepresentable {
static var parentCommand: String? { nil }
static func makeExample(
label: String,
example: String,
includesAppName: Bool = true
) -> AppExample {
.init(
label: label,
parentCommand: parentCommand,
commandName: commandName,
includesAppName: includesAppName,
example: example
)
}
}
extension Abstract where Content == String {
static func `default`(_ string: String) -> Self {
.init { string.blue }
}
}
struct Note<Content: TextNode>: TextNode {
let content: Content
init(
@TextBuilder _ content: () -> Content
) {
self.content = content()
}
var body: some TextNode {
LabeledContent {
content.italic()
} label: {
"Note:".yellow.bold
}
.style(.vertical())
}
}
extension Note where Content == AnyTextNode {
static func `default`(
notes: [String],
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true
) -> Self {
var notes = notes
if usesConfigurationFileNote {
notes.insert(
"Most options are not required when a configuration file is setup for your project.",
at: 0
)
}
if usesConfigurationMergingNote {
if usesConfigurationFileNote {
notes.insert(
"Any configuration options get merged with the loaded project configuration file.",
at: 1
)
} else {
notes.insert(
"Any configuration options get merged with the loaded project configuration file.",
at: 0
)
}
}
return .init {
VStack {
notes.enumerated().map { "\($0 + 1). \($1)" }
}
.eraseToAnyTextNode()
}
}
}
extension Discussion where Content == AnyTextNode {
static func `default`<Preamble: TextNode, Trailing: TextNode>(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true,
@TextBuilder preamble: () -> Preamble,
@TextBuilder trailing: () -> Trailing
) -> Self {
Discussion {
VStack {
preamble().italic()
Note.default(
notes: notes,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote
)
if let examples {
ExampleSection.default(examples: examples, usesExtraOptions: usesExtraOptions)
}
trailing()
}
.separator(.newLine(count: 2))
.eraseToAnyTextNode()
}
}
static func `default`<Preamble: TextNode>(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true,
@TextBuilder preamble: () -> Preamble
) -> Self {
.default(
notes: notes,
examples: examples,
usesExtraOptions: usesExtraOptions,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote,
preamble: preamble,
trailing: {
if usesExtraOptions {
ImportantNote.extraOptionsNote
} else {
Empty()
}
}
)
}
static func `default`(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true
) -> Self {
.default(
notes: notes,
examples: examples,
usesExtraOptions: usesExtraOptions,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote,
preamble: { Empty() },
trailing: { Empty() }
)
}
}
extension ExampleSection where Header == String, Label == String {
static func `default`(
examples: [AppExample] = [],
usesExtraOptions: Bool = true
) -> some TextNode {
var examples: [AppExample] = examples
if usesExtraOptions {
examples = examples.appendingExtraOptionsExample()
}
return Self(
"Examples:",
label: "A few common usage examples.",
examples: examples.map(\.example)
)
.style(AppExampleSectionStyle())
}
}
struct AppExampleSectionStyle: ExampleSectionStyle {
func render(content: ExampleSectionConfiguration) -> some TextNode {
Section {
VStack {
content.examples.map { example in
VStack {
example.label.color(.green).bold()
ShellCommand(example.example).style(.default)
}
}
}
.separator(.newLine(count: 2))
} header: {
HStack {
content.title.color(.blue).bold()
content.label.italic()
}
}
}
}
struct AppExample {
let label: String
let parentCommand: String?
let commandName: String
let includesAppName: Bool
let exampleText: String
init(
label: String,
parentCommand: String? = nil,
commandName: String,
includesAppName: Bool = true,
example exampleText: String
) {
self.label = label
self.parentCommand = parentCommand
self.commandName = commandName
self.includesAppName = includesAppName
self.exampleText = exampleText
}
var example: Example {
var exampleString = "\(commandName) \(exampleText)"
if let parentCommand {
exampleString = "\(parentCommand) \(exampleString)"
}
if includesAppName {
exampleString = "\(Application.commandName) \(exampleString)"
}
return (label: label, example: exampleString)
}
}
extension Array where Element == AppExample {
func appendingExtraOptionsExample() -> Self {
guard let first = first else { return self }
var output = self
output.append(.init(
label: "Passing extra options to custom strategy.",
parentCommand: first.parentCommand,
commandName: first.commandName,
includesAppName: first.includesAppName,
example: "--custom-command -- git describe --tags --exact-match"
))
return output
}
}
struct ImportantNote<Content: TextNode>: TextNode {
let content: Content
init(
@TextBuilder _ content: () -> Content
) {
self.content = content()
}
var body: some TextNode {
LabeledContent {
content.italic()
} label: {
"Important Note:".red.bold
}
.style(.vertical())
}
}
extension ImportantNote where Content == String {
static var extraOptionsNote: Self {
.init {
"""
Extra options / flags for custom strategies must proceed a `--` or you may get an undefined option error.
"""
}
}
}
extension Usage where Content == AnyTextNode {
static func `default`(parentCommand: String? = nil, commandName: String?) -> Self {
var commandString = commandName == nil ? "" : "\(commandName!)"
if let parentCommand {
commandString = "\(parentCommand) \(commandString)"
}
commandString = commandString == "" ? "\(Application.commandName)" : "\(Application.commandName) \(commandString)"
return .init {
HStack {
commandString.blue
"[<options>]".green
"--"
"[<extra-options> ...]".cyan
}
.eraseToAnyTextNode()
}
}
}

View File

@@ -18,72 +18,76 @@ func withSetupDependencies<T>(
} }
} }
extension GlobalOptions { extension CliClient.SharedOptions {
func runClient<T>( func runClient<T>(
_ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> T> _ keyPath: KeyPath<CliClient, @Sendable (Self) async throws -> T>
) async throws -> T { ) async throws -> T {
try await withSetupDependencies { try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient @Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](shared()) return try await cliClient[keyPath: keyPath](self)
} }
} }
func runClient<A, T>( func runClient<A, T>(
_ keyPath: KeyPath<CliClient, @Sendable (A, CliClient.SharedOptions) async throws -> T>, _ keyPath: KeyPath<CliClient, @Sendable (A, Self) async throws -> T>,
args: A args: A
) async throws -> T { ) async throws -> T {
try await withSetupDependencies { try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient @Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](args, shared()) return try await cliClient[keyPath: keyPath](args, self)
} }
} }
}
extension GlobalOptions {
func run( func run(
_ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> String> _ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> String>,
command: String
) async throws { ) async throws {
let output = try await runClient(keyPath) let output = try await shared(command: command).runClient(keyPath)
print(output) print(output)
} }
func run<T>( func run<T>(
_ keyPath: KeyPath<CliClient, @Sendable (T, CliClient.SharedOptions) async throws -> String>, _ keyPath: KeyPath<CliClient, @Sendable (T, CliClient.SharedOptions) async throws -> String>,
command: String,
args: T args: T
) async throws { ) async throws {
let output = try await runClient(keyPath, args: args) let output = try await shared(command: command).runClient(keyPath, args: args)
print(output) print(output)
} }
func shared() throws -> CliClient.SharedOptions { func shared(command: String) throws -> CliClient.SharedOptions {
try .init( try configOptions.shared(
allowPreReleaseTag: !configOptions.semvarOptions.preRelease.disablePreRelease, command: command,
dryRun: dryRun, dryRun: dryRun,
extraOptions: extraOptions,
gitDirectory: gitDirectory, gitDirectory: gitDirectory,
verbose: verbose, verbose: verbose
target: configOptions.target(),
branch: .init(includeCommitSha: configOptions.commitSha),
semvar: configOptions.semvarOptions(extraOptions: extraOptions),
configurationFile: configOptions.configurationFile
) )
} }
} }
private extension TargetOptions { private extension TargetOptions {
func configTarget() throws -> Configuration.Target? { func configTarget() throws -> Configuration.Target? {
guard let path else { guard let targetFilePath else {
guard let module else { guard let targetModule else {
return nil return nil
} }
return .init(module: .init(module, fileName: fileName)) return .init(module: .init(targetModule, fileName: targetFileName))
} }
return .init(path: path) return .init(path: targetFilePath)
} }
} }
extension PreReleaseOptions { extension PreReleaseOptions {
func configPreReleaseStrategy(includeCommitSha: Bool, extraOptions: [String]) throws -> Configuration.PreRelease? { func configPreReleaseStrategy(
includeCommitSha: Bool,
extraOptions: [String]
) throws -> Configuration.PreRelease? {
if useBranchAsPreRelease { if useBranchAsPreRelease {
return .init(prefix: preReleasePrefix, strategy: .branch(includeCommitSha: includeCommitSha)) return .init(prefix: preReleasePrefix, strategy: .branch(includeCommitSha: includeCommitSha))
} else if useTagAsPreRelease { } else if useTagAsPreRelease {
@@ -93,6 +97,8 @@ extension PreReleaseOptions {
throw ExtraOptionsEmpty() throw ExtraOptionsEmpty()
} }
return .init(prefix: preReleasePrefix, strategy: .command(arguments: extraOptions)) return .init(prefix: preReleasePrefix, strategy: .command(arguments: extraOptions))
} else if let preReleasePrefix {
return .init(prefix: preReleasePrefix, strategy: nil)
} }
return nil return nil
} }
@@ -100,11 +106,48 @@ extension PreReleaseOptions {
extension SemVarOptions { extension SemVarOptions {
func configSemVarOptions(includeCommitSha: Bool, extraOptions: [String]) throws -> Configuration.SemVar { func parseStrategy(extraOptions: [String]) throws -> Configuration.SemVar.Strategy? {
try .init( @Dependency(\.logger) var logger
preRelease: preRelease.configPreReleaseStrategy(includeCommitSha: includeCommitSha, extraOptions: extraOptions),
requireExistingFile: requireExistingFile, guard customCommand else {
requireExistingSemVar: requireExistingSemvar guard gitTag else { return nil }
return .gitTag(exactMatch: requireExactMatch)
}
guard extraOptions.count > 0 else {
logger.error("""
Extra options are empty, this does not make sense when using a custom command
strategy.
""")
throw ExtraOptionsEmpty()
}
return .command(arguments: extraOptions)
}
func configSemVarOptions(
includeCommitSha: Bool,
extraOptions: [String]
) throws -> Configuration.SemVar {
@Dependency(\.logger) var logger
// TODO: Update when / if there's an update config command.
if customCommand && preRelease.customPreRelease {
logger.warning("""
Custom pre-release can not be used at same time as custom command.
Ignoring pre-release...
""")
}
return try .init(
allowPreRelease: !preRelease.disablePreRelease,
preRelease: customCommand ? nil : preRelease.configPreReleaseStrategy(
includeCommitSha: includeCommitSha,
extraOptions: extraOptions
),
// Use nil here if false, which makes them not get used in json / file output, which makes
// user config smaller.
requireExistingFile: requireExistingFile ? true : nil,
requireExistingSemVar: requireExistingSemvar ? true : nil,
strategy: parseStrategy(extraOptions: extraOptions)
) )
} }
} }
@@ -115,8 +158,32 @@ extension ConfigurationOptions {
try targetOptions.configTarget() try targetOptions.configTarget()
} }
func semvarOptions(extraOptions: [String]) throws -> Configuration.SemVar { func semvarOptions(
try semvarOptions.configSemVarOptions(includeCommitSha: commitSha, extraOptions: extraOptions) extraOptions: [String]
) throws -> Configuration.SemVar {
try semvarOptions.configSemVarOptions(
includeCommitSha: commitSha,
extraOptions: extraOptions
)
}
func shared(
command: String,
dryRun: Bool = true,
extraOptions: [String] = [],
gitDirectory: String? = nil,
verbose: Int = 0
) throws -> CliClient.SharedOptions {
try .init(
allowPreReleaseTag: !semvarOptions.preRelease.disablePreRelease,
dryRun: dryRun,
gitDirectory: gitDirectory,
loggingOptions: .init(command: command, verbose: verbose),
target: target(),
branch: semvarOptions.gitTag ? nil : .init(includeCommitSha: commitSha),
semvar: semvarOptions(extraOptions: extraOptions),
configurationFile: configurationFile
)
} }
} }

View File

@@ -41,7 +41,9 @@ struct CliClientTests {
#expect(output == "/baz/Sources/bar/Version.swift") #expect(output == "/baz/Sources/bar/Version.swift")
} assert: { string, _ in } assert: { string, _ in
if type != .preRelease {
#expect(string != nil) #expect(string != nil)
}
let typeString = optional ? "String?" : "String" let typeString = optional ? "String?" : "String"
switch type { switch type {
@@ -132,13 +134,12 @@ extension CliClient.SharedOptions {
gitDirectory: String? = "/baz", gitDirectory: String? = "/baz",
dryRun: Bool = false, dryRun: Bool = false,
target: String = "bar", target: String = "bar",
logLevel: Logger.Level = .trace,
versionStrategy: Configuration.VersionStrategy = .semvar(.init()) versionStrategy: Configuration.VersionStrategy = .semvar(.init())
) -> Self { ) -> Self {
return .init( return .init(
dryRun: dryRun, dryRun: dryRun,
gitDirectory: gitDirectory, gitDirectory: gitDirectory,
logLevel: logLevel, loggingOptions: .init(command: "test", verbose: 2),
target: .init(module: .init(target)), target: .init(module: .init(target)),
branch: versionStrategy.branch, branch: versionStrategy.branch,
semvar: versionStrategy.semvar semvar: versionStrategy.semvar

View File

@@ -1,4 +1,4 @@
import ConfigurationClient @_spi(Internal) import ConfigurationClient
import Dependencies import Dependencies
import Foundation import Foundation
import Testing import Testing
@@ -74,6 +74,64 @@ struct ConfigurationClientTests {
} }
} }
@Test
func mergingBranch() {
let branch = Configuration.Branch(includeCommitSha: false)
let branch2 = Configuration.Branch(includeCommitSha: true)
let merged = branch.merging(branch2)
#expect(merged == branch2)
let merged2 = branch.merging(nil)
#expect(merged2 == branch)
}
@Test
func mergingSemvar() {
let strategy1 = Configuration.VersionStrategy.semvar(.init())
let other = Configuration.VersionStrategy.semvar(.init(
allowPreRelease: true,
preRelease: .init(prefix: "foo", strategy: .gitTag),
requireExistingFile: true,
requireExistingSemVar: true,
strategy: .gitTag()
))
let merged = strategy1.merging(other)
#expect(merged == other)
let otherMerged = other.merging(strategy1)
#expect(otherMerged == other)
}
@Test
func mergingTarget() {
let config1 = Configuration(target: .init(path: "foo"))
let config2 = Configuration(target: .init(module: .init("bar")))
let merged = config1.merging(config2)
#expect(merged.target! == .init(module: .init("bar")))
let merged2 = merged.merging(config1)
#expect(merged2.target! == .init(path: "foo"))
let merged3 = merged2.merging(nil)
#expect(merged3 == merged2)
}
@Test
func mergingVersionStrategy() {
let version = Configuration.VersionStrategy.semvar(.init())
let version2 = Configuration.VersionStrategy.branch(.init())
let merged = version.merging(version2)
#expect(merged == version2)
let merged2 = merged.merging(.branch(includeCommitSha: false))
#expect(merged2.branch!.includeCommitSha == false)
let merged3 = version2.merging(version)
#expect(merged3 == version)
}
func run( func run(
setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
operation: () async throws -> Void operation: () async throws -> Void