Compare commits

5 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
23 changed files with 713 additions and 185 deletions

View File

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

View File

@@ -1,5 +1,5 @@
{
"originHash" : "6ab0a9c883cfa1490d249a344074ad27369033fab78e1a90272ef07339a8c0ab",
"originHash" : "3640b52c8069b868611efbfbd9b7545872526454802225747f7cd878062df1db",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -28,6 +28,15 @@
"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",
"kind" : "remoteSourceControl",

View File

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

View File

@@ -4,6 +4,7 @@ import DependenciesMacros
import FileClient
import Foundation
import GitClient
import LoggingExtensions
import ShellClient
public extension DependencyValues {
@@ -34,24 +35,6 @@ public struct CliClient: Sendable {
case major, minor, patch, preRelease
}
// TODO: Need a quiet option, as default log level is warning, need a way to set it to ignore logs.
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 struct SharedOptions: Equatable, Sendable {
let allowPreReleaseTag: Bool
@@ -95,7 +78,9 @@ extension CliClient: DependencyKey {
bump: { try await $1.bump($0) },
generate: { try await $0.generate() },
parsedConfiguration: { options in
try await options.withMergedConfiguration { $0 }
try await options.loggingOptions.withLogger {
try await options.withMergedConfiguration { $0 }
}
}
)
}

View File

@@ -47,18 +47,32 @@ public extension CliClient.SharedOptions {
func withMergedConfiguration<T>(
operation: (Configuration) async throws -> T
) async throws -> T {
try await withConfiguration(path: configurationFile) { configuration in
var configuration = configuration
configuration = configuration.mergingTarget(target)
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
if configuration.strategy?.branch != nil, let branch {
configuration = configuration.mergingStrategy(.branch(branch))
} else if let semvar {
configuration = configuration.mergingStrategy(.semvar(semvar))
}
var strategy: Configuration.VersionStrategy?
return try await operation(configuration)
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 {
@@ -68,7 +82,7 @@ public extension CliClient.SharedOptions {
try await fileClient.write(string: string, to: url)
} else {
logger.debug("Skipping, due to dry-run being passed.")
logger.debug("\n\(string)\n")
logger.info("\n\(string)\n")
}
}
@@ -94,19 +108,19 @@ public struct CurrentVersionContainer: Sendable {
var usesOptionalType: Bool {
switch version {
case .string: return false
case let .semvar(_, usesOptionalType): return usesOptionalType
case let .semvar(_, usesOptionalType, _): return usesOptionalType
}
}
public enum Version: Sendable {
case string(String)
case semvar(SemVar, usesOptionalType: Bool = true)
case semvar(SemVar, usesOptionalType: Bool = true, hasChanges: Bool)
func string(allowPreReleaseTag: Bool) throws -> String {
switch self {
case let .string(string):
return string
case let .semvar(semvar, usesOptionalType: _):
case let .semvar(semvar, usesOptionalType: _, hasChanges: _):
return semvar.versionString(withPreReleaseTag: allowPreReleaseTag)
}
}
@@ -135,14 +149,16 @@ extension CliClient.SharedOptions {
logger.debug("Failed to parse semvar, but got current version string.")
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)")
let bumped = semvar.bump(type)
let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag)
guard bumped != semvar else {
guard bumped != semvar || hasChanges else {
logger.debug("No change, skipping.")
return
}
logger.debug("Bumped version: \(version)")
let template = usesOptionalType ? Template.optional(version) : Template.build(version)
try await write(template, to: container.targetUrl)

View File

@@ -1,82 +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.PreRelease {
func merging(_ other: Self?) -> Self {
.init(
prefix: other?.prefix ?? prefix,
strategy: other?.strategy ?? strategy
)
}
}
extension Configuration.Branch {
func merging(_ other: Self?) -> Self {
return .init(includeCommitSha: other?.includeCommitSha ?? includeCommitSha)
}
}
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,
strategy: other?.strategy ?? strategy
)
}
}
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

@@ -169,9 +169,12 @@ public extension Configuration.SemVar {
// We parsed a semvar from the existing file, use it.
if semVar != nil {
return try await .semvar(
applyingPreRelease(semVar!, gitDirectory),
usesOptionalType: usesOptionalType ?? false
let semvarWithPreRelease = try await applyingPreRelease(semVar!, gitDirectory)
return .semvar(
semvarWithPreRelease,
usesOptionalType: usesOptionalType ?? false,
hasChanges: semvarWithPreRelease != semVar
)
}
@@ -189,9 +192,11 @@ public extension Configuration.SemVar {
)).semVar
if semVar != nil {
return try await .semvar(
applyingPreRelease(semVar!, gitDirectory),
usesOptionalType: usesOptionalType ?? false
let semvarWithPreRelease = try await applyingPreRelease(semVar!, gitDirectory)
return .semvar(
semvarWithPreRelease,
usesOptionalType: usesOptionalType ?? false,
hasChanges: semvarWithPreRelease != semVar
)
}
@@ -204,7 +209,8 @@ public extension Configuration.SemVar {
logger.trace("Generating new semvar.")
return try await .semvar(
applyingPreRelease(.init(), gitDirectory),
usesOptionalType: usesOptionalType ?? false
usesOptionalType: usesOptionalType ?? false,
hasChanges: true
)
}
}

View File

@@ -13,6 +13,7 @@ public struct Template: Sendable {
return """
// Do not set this variable, it is set during the build process.
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 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
/// project directory.
///

View File

@@ -16,6 +16,9 @@ public extension DependencyValues {
@DependencyClient
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.
public var find: @Sendable (URL?) async throws -> URL?
@@ -32,6 +35,25 @@ public struct ConfigurationClient: Sendable {
}
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 {
@@ -39,6 +61,7 @@ extension ConfigurationClient: DependencyKey {
public static var liveValue: ConfigurationClient {
.init(
defaultFileName: { "\(Constants.defaultFileNameWithoutExtension).json" },
find: { try await findConfiguration($0) },
load: { try await loadConfiguration($0) },
write: { try await writeConfiguration($0, to: $1) }

View File

@@ -5,27 +5,19 @@ import LoggingFormatAndPipe
import Rainbow
import ShellClient
// MARK: Custom colors.
extension String {
var orange: Self {
bit24(255, 165, 0)
}
var magena: Self {
// bit24(186, 85, 211)
var magenta: Self {
bit24(238, 130, 238)
}
}
@_spi(Internal)
public extension Logger.Level {
init(verbose: Int) {
switch verbose {
case 1: self = .debug
case 2...: self = .trace
default: self = .warning
}
}
extension Logger.Level {
var coloredString: String {
switch self {
@@ -45,7 +37,7 @@ public extension Logger.Level {
}
}
struct LevelFormatter: LoggingFormatAndPipe.Formatter {
private struct LevelFormatter: LoggingFormatAndPipe.Formatter {
let basic: BasicFormatter
@@ -138,11 +130,11 @@ struct LevelFormatter: LoggingFormatAndPipe.Formatter {
}
extension CliClient.LoggingOptions {
extension LoggingOptions {
func makeLogger() -> Logger {
let formatters: [LogComponent] = [
.text(executableName.magena),
.text(executableName.magenta),
.text(command.blue),
.level,
.group([
@@ -161,12 +153,4 @@ extension CliClient.LoggingOptions {
}
}
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

@@ -0,0 +1,13 @@
import Logging
@_spi(Internal)
public extension Logger.Level {
init(verbose: Int) {
switch verbose {
case 1: self = .debug
case 2...: self = .trace
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,8 +3,10 @@ import Foundation
@main
struct Application: AsyncParsableCommand {
static let commandName = "bump-version"
static let configuration: CommandConfiguration = .init(
commandName: "bump-version",
commandName: commandName,
version: VERSION ?? "0.0.0",
subcommands: [
BuildCommand.self,

View File

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

View File

@@ -1,14 +1,28 @@
import ArgumentParser
import CliClient
import CliDoc
import Dependencies
struct BumpCommand: AsyncParsableCommand {
struct BumpCommand: CommandRepresentable {
static let commandName = "bump"
static let configuration = CommandConfiguration(
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

View File

@@ -1,5 +1,6 @@
import ArgumentParser
import CliClient
import CliDoc
import ConfigurationClient
import CustomDump
import Dependencies
@@ -7,9 +8,11 @@ import FileClient
import Foundation
struct ConfigCommand: AsyncParsableCommand {
static let commandName = "config"
static let configuration = CommandConfiguration(
commandName: "config",
abstract: "Configuration commands",
commandName: commandName,
abstract: Abstract.default("Configuration commands").render(),
subcommands: [
DumpConfig.self,
GenerateConfig.self
@@ -19,19 +22,40 @@ struct ConfigCommand: AsyncParsableCommand {
extension ConfigCommand {
struct DumpConfig: AsyncParsableCommand {
struct DumpConfig: CommandRepresentable {
static let commandName = "dump"
static let parentCommand = ConfigCommand.commandName
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`.
""",
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
@@ -40,14 +64,28 @@ extension ConfigCommand {
.shared(command: Self.commandName)
.runClient(\.parsedConfiguration)
try globals.printConfiguration(configuration)
try globals.printConfiguration(configuration, style: printStyle)
}
}
struct GenerateConfig: AsyncParsableCommand {
struct GenerateConfig: CommandRepresentable {
static let commandName = "generate"
static let parentCommand = ConfigCommand.commandName
static let configuration: CommandConfiguration = .init(
commandName: "generate",
abstract: "Generate a configuration file.",
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"]
)
@@ -56,6 +94,12 @@ extension ConfigCommand {
)
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 {
@@ -67,7 +111,7 @@ extension ConfigCommand {
extraOptions: globals.extraOptions
)
switch globals.printJson {
switch printJson {
case true:
try globals.handlePrintJson(configuration)
case false:
@@ -105,17 +149,18 @@ extension ConfigCommand {
}
}
// TODO: Add verbose.
@dynamicMemberLookup
struct ConfigCommandOptions: ParsableArguments {
@Flag(
name: .customLong("print"),
help: "Print style to stdout."
)
var printJson: Bool = false
@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
@@ -130,10 +175,16 @@ extension ConfigCommand {
}
}
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)
try configOptions.shared(command: command, extraOptions: extraOptions, verbose: verbose)
}
func handlePrintJson(_ configuration: Configuration) throws {
@@ -148,12 +199,22 @@ private extension ConfigCommand.ConfigCommandOptions {
print(string)
}
func printConfiguration(_ configuration: Configuration) throws {
guard printJson else {
func printConfiguration(
_ configuration: Configuration,
style: ConfigCommand.DumpConfig.PrintStyle
) throws {
switch style {
case .json:
try handlePrintJson(configuration)
case .swift:
customDump(configuration)
return
}
try handlePrintJson(configuration)
// guard printJson else {
// customDump(configuration)
// return
// }
// try handlePrintJson(configuration)
}
}

View File

@@ -1,16 +1,24 @@
import ArgumentParser
import CliClient
import CliDoc
import Dependencies
import Foundation
import ShellClient
struct GenerateCommand: AsyncParsableCommand {
struct GenerateCommand: CommandRepresentable {
static let commandName = "generate"
static let configuration: CommandConfiguration = .init(
commandName: Self.commandName,
abstract: "Generates a version file in a command line tool that can be set via the git tag or git sha.",
discussion: "This command can be interacted with directly, outside of the plugin usage context."
abstract: Abstract.default("Generates a version file in your project."),
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

View File

@@ -5,6 +5,8 @@ import Dependencies
import Foundation
import Rainbow
// TODO: Add an option to not load project configuration.
struct GlobalOptions: ParsableArguments {
@OptionGroup
@@ -41,7 +43,7 @@ struct GlobalOptions: ParsableArguments {
struct ConfigurationOptions: ParsableArguments {
@Option(
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"])
)
var configurationFile: String?

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

@@ -180,7 +180,7 @@ extension ConfigurationOptions {
gitDirectory: gitDirectory,
loggingOptions: .init(command: command, verbose: verbose),
target: target(),
branch: .init(includeCommitSha: commitSha),
branch: semvarOptions.gitTag ? nil : .init(includeCommitSha: commitSha),
semvar: semvarOptions(extraOptions: extraOptions),
configurationFile: configurationFile
)

View File

@@ -1,4 +1,4 @@
import ConfigurationClient
@_spi(Internal) import ConfigurationClient
import Dependencies
import Foundation
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(
setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
operation: () async throws -> Void