feat: Updates configuration commands and parsing strategy.
All checks were successful
CI / Ubuntu (push) Successful in 2m59s

This commit is contained in:
2024-12-25 17:31:38 -05:00
parent 3cfd882f38
commit fbb4a22e98
10 changed files with 331 additions and 161 deletions

View File

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

View File

@@ -36,11 +36,14 @@ extension Configuration.Branch {
}
extension Configuration.SemVar {
// TODO: Merge strategy ??
func merging(_ other: Self?) -> Self {
.init(
allowPreRelease: other?.allowPreRelease ?? allowPreRelease,
preRelease: preRelease?.merging(other?.preRelease),
requireExistingFile: other?.requireExistingFile ?? requireExistingFile,
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar,
strategy: other?.strategy ?? strategy
)
}
}

View File

@@ -175,7 +175,7 @@ public extension Configuration.SemVar {
)
}
if requireExistingFile {
if requireExistingFile == true {
logger.debug("Failed to parse existing file, and caller requires it.")
throw CliClientError.fileDoesNotExist(path: file.cleanFilePath)
}
@@ -195,7 +195,7 @@ public extension Configuration.SemVar {
)
}
if requireExistingSemVar {
if requireExistingSemVar == true {
logger.trace("Caller requires existing semvar and it was not found in file or git-tag.")
throw CliClientError.semVarNotFound
}

View File

@@ -26,7 +26,7 @@ public struct Configuration: Codable, Equatable, Sendable {
public static func mock(module: String = "cli-version") -> Self {
.init(
target: .init(module: .init(module)),
strategy: .semvar(.init())
strategy: .semvar(.init(strategy: .gitTag(exactMatch: false)))
)
}
@@ -99,28 +99,36 @@ public extension Configuration {
///
struct SemVar: Codable, Equatable, Sendable {
public let allowPreRelease: Bool?
/// Optional pre-releas suffix strategy.
public let preRelease: PreRelease?
/// 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.
public let requireExistingSemVar: Bool
public let requireExistingSemVar: Bool?
public let strategy: Strategy?
public init(
allowPreRelease: Bool? = true,
preRelease: PreRelease? = nil,
requireExistingFile: Bool = true,
requireExistingSemVar: Bool = true
requireExistingFile: Bool? = true,
requireExistingSemVar: Bool? = true,
strategy: Strategy? = nil
) {
self.allowPreRelease = allowPreRelease
self.preRelease = preRelease
self.requireExistingFile = requireExistingFile
self.requireExistingSemVar = requireExistingSemVar
self.strategy = strategy
}
public enum Strategy: Codable, Equatable, Sendable {
case command(arguments: [String])
case gitTag(exactMatch: Bool = false)
case gitTag(exactMatch: Bool? = false)
}
}
@@ -172,7 +180,7 @@ public extension Configuration {
/// - fileName: The file name located in the module directory.
public init(
_ name: String,
fileName: String? = "Version.swift"
fileName: String? = nil
) {
self.name = name
self.fileName = fileName
@@ -225,9 +233,11 @@ public extension Configuration {
case branch(includeCommitSha: Bool = true)
case semvar(
allowPreRelease: Bool? = nil,
preRelease: PreRelease? = nil,
requireExistingFile: Bool? = nil,
requireExistingSemVar: Bool? = nil
requireExistingSemVar: Bool? = nil,
strategy: SemVar.Strategy? = nil
)
public var branch: Branch? {
@@ -238,12 +248,14 @@ public extension Configuration {
}
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 }
return .init(
allowPreRelease: allowPreRelease,
preRelease: preRelease,
requireExistingFile: requireExistingFile ?? false,
requireExistingSemVar: requireExistingSemVar ?? false
requireExistingSemVar: requireExistingSemVar ?? false,
strategy: strategy
)
}
@@ -253,9 +265,11 @@ public extension Configuration {
public static func semvar(_ value: SemVar) -> Self {
.semvar(
allowPreRelease: value.allowPreRelease,
preRelease: value.preRelease,
requireExistingFile: value.requireExistingFile,
requireExistingSemVar: value.requireExistingSemVar
requireExistingSemVar: value.requireExistingSemVar,
strategy: value.strategy
)
}

View File

@@ -10,7 +10,7 @@ struct Application: AsyncParsableCommand {
BuildCommand.self,
BumpCommand.self,
GenerateCommand.self,
UtilsCommand.self
ConfigCommand.self
],
defaultSubcommand: BumpCommand.self
)

View File

@@ -0,0 +1,171 @@
import ArgumentParser
import CliClient
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
struct ConfigCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "config",
abstract: "Configuration commands",
subcommands: [
DumpConfig.self,
GenerateConfig.self
]
)
}
extension ConfigCommand {
struct DumpConfig: AsyncParsableCommand {
static let commandName = "dump"
static let configuration = CommandConfiguration(
commandName: Self.commandName,
abstract: "Inspect the parsed configuration.",
discussion: """
This will load any configuration and merge the options passed in. Then print it to stdout.
The default style is to print the output in `swift`, however you can use the `--print` flag to
print the output in `json`.
""",
aliases: ["d"]
)
@OptionGroup var globals: ConfigCommandOptions
func run() async throws {
let configuration = try await globals
.shared(command: Self.commandName)
.runClient(\.parsedConfiguration)
try globals.printConfiguration(configuration)
}
}
struct GenerateConfig: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "generate",
abstract: "Generate a configuration file.",
aliases: ["g"]
)
@Flag(
help: "The style of the configuration."
)
var style: ConfigCommand.Style = .semvar
@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 globals.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
)
}
}
@dynamicMemberLookup
struct ConfigCommandOptions: ParsableArguments {
@Flag(
name: .customLong("print"),
help: "Print style to stdout."
)
var printJson: Bool = false
@OptionGroup var configOptions: ConfigurationOptions
@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.ConfigCommandOptions {
func shared(command: String) throws -> CliClient.SharedOptions {
try configOptions.shared(command: command, extraOptions: extraOptions)
}
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) throws {
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,97 +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 commandName = "dump-config"
static let configuration = CommandConfiguration(
commandName: Self.commandName,
abstract: "Show the parsed configuration.",
aliases: ["dc"]
)
@OptionGroup var globals: GlobalOptions
func run() async throws {
let configuration = try await globals.runClient(\.parsedConfiguration, command: Self.commandName)
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

@@ -31,7 +31,7 @@ struct GlobalOptions: ParsableArguments {
@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.
the command. These are ignored if the `--custom` or `--custom-pre-release` flag is not set.
"""
)
var extraOptions: [String] = []
@@ -40,7 +40,7 @@ struct GlobalOptions: ParsableArguments {
struct ConfigurationOptions: ParsableArguments {
@Option(
name: .shortAndLong,
name: [.customShort("f"), .long],
help: "Specify the path to a configuration file.",
completion: .file(extensions: ["json"])
)
@@ -63,22 +63,22 @@ struct ConfigurationOptions: ParsableArguments {
struct TargetOptions: ParsableArguments {
@Option(
name: .shortAndLong,
name: [.customShort("p"), .long],
help: "Path to the version file, not required if module is set."
)
var path: String?
var targetFilePath: String?
@Option(
name: .shortAndLong,
name: [.customShort("m"), .long],
help: "The target module name or directory path, not required if path is set."
)
var module: String?
var targetModule: String?
@Option(
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 +91,7 @@ struct PreReleaseOptions: ParsableArguments {
var disablePreRelease: Bool = false
@Flag(
name: [.customShort("s"), .customLong("pre-release-branch-style")],
name: [.customShort("b"), .customLong("pre-release-branch-style")],
help: """
Use branch name and commit sha for pre-release suffix, ignored if branch is set.
"""
@@ -124,8 +124,22 @@ struct PreReleaseOptions: ParsableArguments {
}
// TODO: Add custom command strategy.
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(
name: .long,
help: """
@@ -136,9 +150,18 @@ struct SemVarOptions: ParsableArguments {
@Flag(
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
@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
}

View File

@@ -18,34 +18,35 @@ func withSetupDependencies<T>(
}
}
extension GlobalOptions {
extension CliClient.SharedOptions {
func runClient<T>(
_ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> T>,
command: String
_ keyPath: KeyPath<CliClient, @Sendable (Self) async throws -> T>
) async throws -> T {
try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](shared(command: command))
return try await cliClient[keyPath: keyPath](self)
}
}
func runClient<A, T>(
_ keyPath: KeyPath<CliClient, @Sendable (A, CliClient.SharedOptions) async throws -> T>,
command: String,
_ keyPath: KeyPath<CliClient, @Sendable (A, Self) async throws -> T>,
args: A
) async throws -> T {
try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](args, shared(command: command))
return try await cliClient[keyPath: keyPath](args, self)
}
}
}
extension GlobalOptions {
func run(
_ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> String>,
command: String
) async throws {
let output = try await runClient(keyPath, command: command)
let output = try await shared(command: command).runClient(keyPath)
print(output)
}
@@ -54,40 +55,39 @@ extension GlobalOptions {
command: String,
args: T
) async throws {
let output = try await runClient(keyPath, command: command, args: args)
let output = try await shared(command: command).runClient(keyPath, args: args)
print(output)
}
func shared(command: String) throws -> CliClient.SharedOptions {
try .init(
allowPreReleaseTag: !configOptions.semvarOptions.preRelease.disablePreRelease,
try configOptions.shared(
command: command,
dryRun: dryRun,
extraOptions: extraOptions,
gitDirectory: gitDirectory,
loggingOptions: .init(command: command, verbose: verbose),
target: configOptions.target(),
branch: .init(includeCommitSha: configOptions.commitSha),
semvar: configOptions.semvarOptions(extraOptions: extraOptions),
configurationFile: configOptions.configurationFile
verbose: verbose
)
}
}
private extension TargetOptions {
func configTarget() throws -> Configuration.Target? {
guard let path else {
guard let module else {
guard let targetFilePath else {
guard let targetModule else {
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 {
func configPreReleaseStrategy(includeCommitSha: Bool, extraOptions: [String]) throws -> Configuration.PreRelease? {
func configPreReleaseStrategy(
includeCommitSha: Bool,
extraOptions: [String]
) throws -> Configuration.PreRelease? {
if useBranchAsPreRelease {
return .init(prefix: preReleasePrefix, strategy: .branch(includeCommitSha: includeCommitSha))
} else if useTagAsPreRelease {
@@ -106,11 +106,48 @@ extension PreReleaseOptions {
extension SemVarOptions {
func configSemVarOptions(includeCommitSha: Bool, extraOptions: [String]) throws -> Configuration.SemVar {
try .init(
preRelease: preRelease.configPreReleaseStrategy(includeCommitSha: includeCommitSha, extraOptions: extraOptions),
requireExistingFile: requireExistingFile,
requireExistingSemVar: requireExistingSemvar
func parseStrategy(extraOptions: [String]) throws -> Configuration.SemVar.Strategy? {
@Dependency(\.logger) var logger
guard customCommand else {
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)
)
}
}
@@ -121,8 +158,32 @@ extension ConfigurationOptions {
try targetOptions.configTarget()
}
func semvarOptions(extraOptions: [String]) throws -> Configuration.SemVar {
try semvarOptions.configSemVarOptions(includeCommitSha: commitSha, extraOptions: extraOptions)
func semvarOptions(
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: .init(includeCommitSha: commitSha),
semvar: semvarOptions(extraOptions: extraOptions),
configurationFile: configurationFile
)
}
}

View File

@@ -44,7 +44,6 @@ struct CliClientTests {
if type != .preRelease {
#expect(string != nil)
}
let typeString = optional ? "String?" : "String"
switch type {