diff --git a/Package.swift b/Package.swift index a15647f..289f06f 100644 --- a/Package.swift +++ b/Package.swift @@ -40,7 +40,8 @@ let package = Package( "ConfigurationClient", "FileClient", "GitClient", - .product(name: "Logging", package: "swift-log") + .product(name: "Logging", package: "swift-log"), + .product(name: "CustomDump", package: "swift-custom-dump") ] ), .testTarget( diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index 438c84d..61d4c09 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -34,12 +34,30 @@ 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 let dryRun: Bool let gitDirectory: String? - let logLevel: Logger.Level + let loggingOptions: LoggingOptions let target: Configuration.Target? let branch: Configuration.Branch? let semvar: Configuration.SemVar? @@ -49,7 +67,7 @@ public struct CliClient: Sendable { allowPreReleaseTag: Bool = true, dryRun: Bool = false, gitDirectory: String? = nil, - logLevel: Logger.Level = .debug, + loggingOptions: LoggingOptions, target: Configuration.Target? = nil, branch: Configuration.Branch? = nil, semvar: Configuration.SemVar? = nil, @@ -58,34 +76,12 @@ public struct CliClient: Sendable { self.allowPreReleaseTag = allowPreReleaseTag self.dryRun = dryRun self.gitDirectory = gitDirectory - self.logLevel = logLevel + self.loggingOptions = loggingOptions self.target = target self.branch = branch self.semvar = semvar 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 - ) - } } } diff --git a/Sources/CliClient/CliClientError.swift b/Sources/CliClient/CliClientError.swift index 2f8e488..67a2a3d 100644 --- a/Sources/CliClient/CliClientError.swift +++ b/Sources/CliClient/CliClientError.swift @@ -4,4 +4,5 @@ enum CliClientError: Error { case fileDoesNotExist(path: String) case failedToParseVersionFile case semVarNotFound + case preReleaseParsingError(String) } diff --git a/Sources/CliClient/Internal/Internal.swift b/Sources/CliClient/Internal/CliClient+run.swift similarity index 91% rename from Sources/CliClient/Internal/Internal.swift rename to Sources/CliClient/Internal/CliClient+run.swift index a1d9db8..933d9be 100644 --- a/Sources/CliClient/Internal/Internal.swift +++ b/Sources/CliClient/Internal/CliClient+run.swift @@ -1,4 +1,5 @@ import ConfigurationClient +import CustomDump import Dependencies import FileClient import Foundation @@ -7,6 +8,40 @@ import GitClient @_spi(Internal) public extension CliClient.SharedOptions { + /// All cli-client calls should run through this, it set's up logging, + /// loads configuration, and generates the current version based on the + /// configuration. + @discardableResult + func run( + _ operation: (CurrentVersionContainer) async throws -> Void + ) async rethrows -> String { + try await loggingOptions.withLogger { + // Load the default configuration, if it exists. + try await withMergedConfiguration { configuration in + @Dependency(\.logger) var logger + + var configurationString = "" + customDump(configuration, to: &configurationString) + logger.trace("\nConfiguration: \(configurationString)") + + // This will fail if the target url is not set properly. + let targetUrl = try configuration.targetUrl(gitDirectory: gitDirectory) + logger.debug("Target: \(targetUrl.cleanFilePath)") + + // Perform the operation, which generates the new version and writes it. + try await operation( + configuration.currentVersion( + targetUrl: targetUrl, + gitDirectory: gitDirectory + ) + ) + + // Return the file path we wrote the version to. + return targetUrl.cleanFilePath + } + } + } + // Merges any configuration set via the passed in options. @discardableResult func withMergedConfiguration( @@ -26,37 +61,6 @@ public extension CliClient.SharedOptions { } } - @discardableResult - func run( - _ operation: (CurrentVersionContainer) async throws -> Void - ) async rethrows -> String { - try await withDependencies { - $0.logger.logLevel = logLevel - } operation: { - // Load the default configuration, if it exists. - try await withMergedConfiguration { configuration in - @Dependency(\.logger) var logger - - logger.debug("Configuration: \(configuration)") - - // This will fail if the target url is not set properly. - let targetUrl = try configuration.targetUrl(gitDirectory: gitDirectory) - logger.debug("Target: \(targetUrl.cleanFilePath)") - - // Perform the operation, which generates the new version and writes it. - try await operation( - configuration.currentVersion( - targetUrl: targetUrl, - gitDirectory: gitDirectory - ) - ) - - // Return the file path we wrote the version to. - return targetUrl.cleanFilePath - } - } - } - func write(_ string: String, to url: URL) async throws { @Dependency(\.fileClient) var fileClient @Dependency(\.logger) var logger @@ -64,7 +68,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("\(string)") + logger.debug("\n\(string)\n") } } diff --git a/Sources/CliClient/Internal/Configuration+merging.swift b/Sources/CliClient/Internal/Configuration+merging.swift index 5b66dac..c252154 100644 --- a/Sources/CliClient/Internal/Configuration+merging.swift +++ b/Sources/CliClient/Internal/Configuration+merging.swift @@ -20,6 +20,15 @@ extension Configuration { } } +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) @@ -29,7 +38,7 @@ extension Configuration.Branch { extension Configuration.SemVar { func merging(_ other: Self?) -> Self { .init( - preRelease: other?.preRelease ?? preRelease, + preRelease: preRelease?.merging(other?.preRelease), requireExistingFile: other?.requireExistingFile ?? requireExistingFile, requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar ) diff --git a/Sources/CliClient/Internal/ConfigurationExtensions.swift b/Sources/CliClient/Internal/ConfigurationExtensions.swift index a393e6a..2c2d8c0 100644 --- a/Sources/CliClient/Internal/ConfigurationExtensions.swift +++ b/Sources/CliClient/Internal/ConfigurationExtensions.swift @@ -2,6 +2,7 @@ import ConfigurationClient import Dependencies import Foundation import GitClient +import ShellClient extension Configuration { func targetUrl(gitDirectory: String?) throws -> URL { @@ -58,26 +59,46 @@ extension Configuration.Target { } 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 return try await gitClient.version(.init( gitDirectory: gitDirectory, - style: .branch(commitSha: branch.includeCommitSha) + style: .branch(commitSha: includeCommitSha) )).description } } 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(\.logger) var logger - let preReleaseString: String + var preReleaseString: String + var suffix = true + var allowsPrefix = true - if let branch = strategy?.branch { - preReleaseString = try await gitClient.version(branch: branch, gitDirectory: gitDirectory) - } else { + switch strategy { + case let .branch(includeCommitSha: includeCommitSha): + logger.trace("Branch pre-release strategy, includeCommitSha: \(includeCommitSha).") + preReleaseString = try await gitClient.version( + includeCommitSha: includeCommitSha, + gitDirectory: gitDirectory + ) + case let .command(arguments: arguments): + 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( gitDirectory: gitDirectory, style: .tag(exactMatch: false) @@ -85,9 +106,22 @@ extension Configuration.PreRelease { } 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) } } @@ -97,15 +131,28 @@ public extension Configuration.SemVar { private func applyingPreRelease(_ semVar: SemVar, _ gitDirectory: String?) async throws -> SemVar { @Dependency(\.logger) var logger logger.trace("Start apply pre-release to: \(semVar)") - guard let preReleaseStrategy = self.preRelease else { + + guard let preReleaseStrategy = self.preRelease, + let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory) + else { logger.trace("No pre-release strategy, returning original semvar.") return semVar } - let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory) + // let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory) 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 { @@ -181,7 +228,7 @@ extension Configuration.VersionStrategy { return try await .init( targetUrl: targetUrl, version: .string( - gitClient.version(branch: branch, gitDirectory: gitDirectory) + gitClient.version(includeCommitSha: branch.includeCommitSha, gitDirectory: gitDirectory) ) ) } diff --git a/Sources/CliClient/Internal/FileClient+semVar.swift b/Sources/CliClient/Internal/FileClient+semVar.swift index afc1b5a..fdbde13 100644 --- a/Sources/CliClient/Internal/FileClient+semVar.swift +++ b/Sources/CliClient/Internal/FileClient+semVar.swift @@ -28,13 +28,13 @@ public extension FileClient { logger.debug("Version line: \(versionLine)") 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 guard let versionString else { throw CliClientError.failedToParseVersionFile } - logger.debug("Parsed version string: \(versionString)") + logger.trace("Parsed version string: \(versionString)") return (String(versionString), isOptional) } diff --git a/Sources/CliClient/Internal/Helpers.swift b/Sources/CliClient/Internal/Helpers.swift deleted file mode 100644 index 632de7c..0000000 --- a/Sources/CliClient/Internal/Helpers.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Logging - -// TODO: Move. -@_spi(Internal) -public extension Logger.Level { - - init(verbose: Int) { - switch verbose { - case 1: self = .warning - case 2: self = .debug - case 3...: self = .trace - default: self = .info - } - } -} diff --git a/Sources/CliClient/Internal/Logging.swift b/Sources/CliClient/Internal/Logging.swift new file mode 100644 index 0000000..ef521d8 --- /dev/null +++ b/Sources/CliClient/Internal/Logging.swift @@ -0,0 +1,172 @@ +import Dependencies +import Foundation +import Logging +import LoggingFormatAndPipe +import Rainbow +import ShellClient + +extension String { + var orange: Self { + bit24(255, 165, 0) + } + + var magena: Self { + // bit24(186, 85, 211) + 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 + } + } + + 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)" + } + } +} + +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 CliClient.LoggingOptions { + + func makeLogger() -> Logger { + let formatters: [LogComponent] = [ + .text(executableName.magena), + .text(command.blue), + .level, + .group([ + .text("-> "), + .message + ]) + ] + return Logger(label: executableName) { _ in + LoggingFormatAndPipe.Handler( + formatter: LevelFormatter(basic: BasicFormatter( + formatters, + separator: " | " + )), + pipe: LoggerTextOutputStreamPipe.standardOutput + ) + } + } + + func withLogger(_ operation: () async throws -> T) async rethrows -> T { + try await withDependencies { + $0.logger = makeLogger() + $0.logger.logLevel = .init(verbose: verbose) + } operation: { + try await operation() + } + } +} diff --git a/Sources/bump-version/Commands/BuildCommand.swift b/Sources/bump-version/Commands/BuildCommand.swift index 811c8b1..38824d3 100644 --- a/Sources/bump-version/Commands/BuildCommand.swift +++ b/Sources/bump-version/Commands/BuildCommand.swift @@ -4,8 +4,10 @@ import Foundation import ShellClient struct BuildCommand: AsyncParsableCommand { + static let commandName = "build" + static let configuration: CommandConfiguration = .init( - commandName: "build", + 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.", shouldDisplay: false @@ -14,6 +16,6 @@ struct BuildCommand: AsyncParsableCommand { @OptionGroup var globals: GlobalOptions func run() async throws { - try await globals.run(\.build) + try await globals.run(\.build, command: Self.commandName) } } diff --git a/Sources/bump-version/Commands/BumpCommand.swift b/Sources/bump-version/Commands/BumpCommand.swift index 50af2a5..fba2a87 100644 --- a/Sources/bump-version/Commands/BumpCommand.swift +++ b/Sources/bump-version/Commands/BumpCommand.swift @@ -4,8 +4,10 @@ import Dependencies struct BumpCommand: AsyncParsableCommand { + static let commandName = "bump" + static let configuration = CommandConfiguration( - commandName: "bump", + commandName: Self.commandName, abstract: "Bump version of a command-line tool." ) @@ -19,7 +21,7 @@ struct BumpCommand: AsyncParsableCommand { var bumpOption: CliClient.BumpOption = .patch func run() async throws { - try await globals.run(\.bump, args: bumpOption) + try await globals.run(\.bump, command: Self.commandName, args: bumpOption) } } diff --git a/Sources/bump-version/Commands/GenerateCommand.swift b/Sources/bump-version/Commands/GenerateCommand.swift index 5099e2c..274fb83 100644 --- a/Sources/bump-version/Commands/GenerateCommand.swift +++ b/Sources/bump-version/Commands/GenerateCommand.swift @@ -5,8 +5,10 @@ import Foundation import ShellClient struct GenerateCommand: AsyncParsableCommand { + static let commandName = "generate" + 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.", discussion: "This command can be interacted with directly, outside of the plugin usage context." ) @@ -14,6 +16,6 @@ struct GenerateCommand: AsyncParsableCommand { @OptionGroup var globals: GlobalOptions func run() async throws { - try await globals.run(\.generate) + try await globals.run(\.generate, command: Self.commandName) } } diff --git a/Sources/bump-version/Commands/UtilsCommand.swift b/Sources/bump-version/Commands/UtilsCommand.swift index 54c2ffc..ebaacfa 100644 --- a/Sources/bump-version/Commands/UtilsCommand.swift +++ b/Sources/bump-version/Commands/UtilsCommand.swift @@ -18,8 +18,10 @@ struct UtilsCommand: AsyncParsableCommand { extension UtilsCommand { struct DumpConfig: AsyncParsableCommand { + static let commandName = "dump-config" + static let configuration = CommandConfiguration( - commandName: "dump-config", + commandName: Self.commandName, abstract: "Show the parsed configuration.", aliases: ["dc"] ) @@ -27,7 +29,7 @@ extension UtilsCommand { @OptionGroup var globals: GlobalOptions func run() async throws { - let configuration = try await globals.runClient(\.parsedConfiguration) + let configuration = try await globals.runClient(\.parsedConfiguration, command: Self.commandName) customDump(configuration) } } diff --git a/Sources/bump-version/Helpers/GlobalOptions+run.swift b/Sources/bump-version/Helpers/GlobalOptions+run.swift index 1eef904..ba043f2 100644 --- a/Sources/bump-version/Helpers/GlobalOptions+run.swift +++ b/Sources/bump-version/Helpers/GlobalOptions+run.swift @@ -21,45 +21,49 @@ func withSetupDependencies( extension GlobalOptions { func runClient( - _ keyPath: KeyPath T> + _ keyPath: KeyPath T>, + command: String ) async throws -> T { try await withSetupDependencies { @Dependency(\.cliClient) var cliClient - return try await cliClient[keyPath: keyPath](shared()) + return try await cliClient[keyPath: keyPath](shared(command: command)) } } func runClient( _ keyPath: KeyPath T>, + command: String, args: A ) async throws -> T { try await withSetupDependencies { @Dependency(\.cliClient) var cliClient - return try await cliClient[keyPath: keyPath](args, shared()) + return try await cliClient[keyPath: keyPath](args, shared(command: command)) } } func run( - _ keyPath: KeyPath String> + _ keyPath: KeyPath String>, + command: String ) async throws { - let output = try await runClient(keyPath) + let output = try await runClient(keyPath, command: command) print(output) } func run( _ keyPath: KeyPath String>, + command: String, args: T ) async throws { - let output = try await runClient(keyPath, args: args) + let output = try await runClient(keyPath, command: command, args: args) print(output) } - func shared() throws -> CliClient.SharedOptions { + func shared(command: String) throws -> CliClient.SharedOptions { try .init( allowPreReleaseTag: !configOptions.semvarOptions.preRelease.disablePreRelease, dryRun: dryRun, gitDirectory: gitDirectory, - verbose: verbose, + loggingOptions: .init(command: command, verbose: verbose), target: configOptions.target(), branch: .init(includeCommitSha: configOptions.commitSha), semvar: configOptions.semvarOptions(extraOptions: extraOptions), @@ -93,6 +97,8 @@ extension PreReleaseOptions { throw ExtraOptionsEmpty() } return .init(prefix: preReleasePrefix, strategy: .command(arguments: extraOptions)) + } else if let preReleasePrefix { + return .init(prefix: preReleasePrefix, strategy: nil) } return nil } diff --git a/Tests/CliVersionTests/CliClientTests.swift b/Tests/CliVersionTests/CliClientTests.swift index d599827..7b35c73 100644 --- a/Tests/CliVersionTests/CliClientTests.swift +++ b/Tests/CliVersionTests/CliClientTests.swift @@ -132,13 +132,12 @@ extension CliClient.SharedOptions { gitDirectory: String? = "/baz", dryRun: Bool = false, target: String = "bar", - logLevel: Logger.Level = .trace, versionStrategy: Configuration.VersionStrategy = .semvar(.init()) ) -> Self { return .init( dryRun: dryRun, gitDirectory: gitDirectory, - logLevel: logLevel, + loggingOptions: .init(command: "test", verbose: 2), target: .init(module: .init(target)), branch: versionStrategy.branch, semvar: versionStrategy.semvar