diff --git a/.bump-version.json b/.bump-version.json index 730d942..76e2cff 100644 --- a/.bump-version.json +++ b/.bump-version.json @@ -1,14 +1,10 @@ { + "target" : { + "module" : { "name" : "cli-version" } + }, "strategy" : { "semvar" : { - "preRelease" : { - "strategy" : { "gitTag" : {} } - } - } - }, - "target" : { - "module" : { - "name" : "bump-version" + "strategy" : { "gitTag": { "exactMatch": false } } } } } diff --git a/Sources/CliClient/Internal/Configuration+merging.swift b/Sources/CliClient/Internal/Configuration+merging.swift index c252154..8fbf6b5 100644 --- a/Sources/CliClient/Internal/Configuration+merging.swift +++ b/Sources/CliClient/Internal/Configuration+merging.swift @@ -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 ) } } diff --git a/Sources/CliClient/Internal/ConfigurationExtensions.swift b/Sources/CliClient/Internal/ConfigurationExtensions.swift index 56d8a4f..65f008d 100644 --- a/Sources/CliClient/Internal/ConfigurationExtensions.swift +++ b/Sources/CliClient/Internal/ConfigurationExtensions.swift @@ -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 } diff --git a/Sources/ConfigurationClient/Configuration.swift b/Sources/ConfigurationClient/Configuration.swift index 39e0ddb..a60f0f3 100644 --- a/Sources/ConfigurationClient/Configuration.swift +++ b/Sources/ConfigurationClient/Configuration.swift @@ -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 ) } diff --git a/Sources/bump-version/Application.swift b/Sources/bump-version/Application.swift index 3ad6c74..01f2743 100644 --- a/Sources/bump-version/Application.swift +++ b/Sources/bump-version/Application.swift @@ -10,7 +10,7 @@ struct Application: AsyncParsableCommand { BuildCommand.self, BumpCommand.self, GenerateCommand.self, - UtilsCommand.self + ConfigCommand.self ], defaultSubcommand: BumpCommand.self ) diff --git a/Sources/bump-version/Commands/ConfigCommand.swift b/Sources/bump-version/Commands/ConfigCommand.swift new file mode 100644 index 0000000..281adc0 --- /dev/null +++ b/Sources/bump-version/Commands/ConfigCommand.swift @@ -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(dynamicMember keyPath: KeyPath) -> 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 {} diff --git a/Sources/bump-version/Commands/UtilsCommand.swift b/Sources/bump-version/Commands/UtilsCommand.swift deleted file mode 100644 index ebaacfa..0000000 --- a/Sources/bump-version/Commands/UtilsCommand.swift +++ /dev/null @@ -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 - } -} diff --git a/Sources/bump-version/GlobalOptions.swift b/Sources/bump-version/GlobalOptions.swift index 01260eb..2174e36 100644 --- a/Sources/bump-version/GlobalOptions.swift +++ b/Sources/bump-version/GlobalOptions.swift @@ -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 } diff --git a/Sources/bump-version/Helpers/GlobalOptions+run.swift b/Sources/bump-version/Helpers/GlobalOptions+run.swift index ba043f2..72edb9b 100644 --- a/Sources/bump-version/Helpers/GlobalOptions+run.swift +++ b/Sources/bump-version/Helpers/GlobalOptions+run.swift @@ -18,34 +18,35 @@ func withSetupDependencies( } } -extension GlobalOptions { - +extension CliClient.SharedOptions { func runClient( - _ keyPath: KeyPath T>, - command: String + _ keyPath: KeyPath 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( - _ keyPath: KeyPath T>, - command: String, + _ keyPath: KeyPath 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 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 + ) } } diff --git a/Tests/CliVersionTests/CliClientTests.swift b/Tests/CliVersionTests/CliClientTests.swift index 0d8fdd5..7cc91b8 100644 --- a/Tests/CliVersionTests/CliClientTests.swift +++ b/Tests/CliVersionTests/CliClientTests.swift @@ -44,7 +44,6 @@ struct CliClientTests { if type != .preRelease { #expect(string != nil) } - let typeString = optional ? "String?" : "String" switch type {