From 9b60ba0e6c5df7f201af0e98c2cd573dfc23d3c9 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Mon, 23 Dec 2024 14:26:41 -0500 Subject: [PATCH] feat: Commit pre integrating configuration client into cli-client. --- Package.resolved | 11 +- Package.swift | 7 +- Sources/CliClient/CliClient.swift | 5 + .../Internal/ConfigurationExtensions.swift | 148 ++++++++++++++++++ .../VersionStrategy+currentVersion.swift | 1 + .../ConfigurationClient/Configuration.swift | 6 +- .../ConfigurationClient.swift | 26 +-- .../ConfigurationFile.swift | 6 +- Sources/cli-version/CliVersionCommand.swift | 3 +- Sources/cli-version/GlobalOptions.swift | 39 +++++ Sources/cli-version/UtilsCommand.swift | 48 ++++++ Tests/CliVersionTests/CliVersionTests.swift | 20 +-- .../ConfigurationClientTests.swift | 58 ++++++- 13 files changed, 347 insertions(+), 31 deletions(-) create mode 100644 Sources/CliClient/Internal/ConfigurationExtensions.swift create mode 100644 Sources/cli-version/UtilsCommand.swift diff --git a/Package.resolved b/Package.resolved index f676a1f..b285ab7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b7e59cc29214df682ee1aa756371133e94660f8aabd03c193d455ba057ed5d17", + "originHash" : "a6f314a56cd0c1a50e5cace4aacf75ecda58c4cc00e5e13bf7ec110289f943bf", "pins" : [ { "identity" : "combine-schedulers", @@ -46,6 +46,15 @@ "version" : "1.3.1" } }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump.git", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index c4091ef..6ffd377 100644 --- a/Package.swift +++ b/Package.swift @@ -19,19 +19,22 @@ let package = Package( .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"), - .package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0") + .package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3") ], targets: [ .executableTarget( name: "cli-version", dependencies: [ "CliClient", - .product(name: "ArgumentParser", package: "swift-argument-parser") + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "CustomDump", package: "swift-custom-dump") ] ), .target( name: "CliClient", dependencies: [ + "ConfigurationClient", "FileClient", "GitClient", .product(name: "Logging", package: "swift-log") diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index 898a486..3540141 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -1,3 +1,4 @@ +import ConfigurationClient import Dependencies import DependenciesMacros import FileClient @@ -5,6 +6,8 @@ import Foundation import GitClient import ShellClient +// TODO: Integrate ConfigurationClient + public extension DependencyValues { var cliClient: CliClient { @@ -45,6 +48,8 @@ public struct CliClient: Sendable { case branchAndCommit case semVar(SemVarOptions) + // public typealias SemVarOptions = Configuration.SemVar + public struct SemVarOptions: Equatable, Sendable { let preReleaseStrategy: PreReleaseStrategy? let requireExistingFile: Bool diff --git a/Sources/CliClient/Internal/ConfigurationExtensions.swift b/Sources/CliClient/Internal/ConfigurationExtensions.swift new file mode 100644 index 0000000..d1aa753 --- /dev/null +++ b/Sources/CliClient/Internal/ConfigurationExtensions.swift @@ -0,0 +1,148 @@ +import ConfigurationClient +import Dependencies +import Foundation +import GitClient + +extension Configuration.Target { + func url(gitDirectory: String?) throws -> URL { + let filePath: String + + if let path { + filePath = path + } else { + guard let module else { + throw ConfigurationParsingError.pathOrModuleNotSet + } + + var path = module.name + if !path.hasPrefix("Sources") || !path.hasPrefix("./Sources") { + path = "Sources/\(path)" + } + + if path.hasPrefix("./") { + path = String(path.dropFirst(2)) + } + filePath = "\(path)/\(module.fileName)" + } + + if let gitDirectory { + return URL(filePath: "\(gitDirectory)/\(filePath)") + } + return URL(filePath: filePath) + } +} + +extension GitClient { + func version(branch: Configuration.Branch, gitDirectory: String?) async throws -> String { + @Dependency(\.gitClient) var gitClient + + return try await gitClient.version(.init( + gitDirectory: gitDirectory, + style: .branch(commitSha: branch.includeCommitSha) + )).description + } +} + +extension Configuration.PreReleaseStrategy { + + func preReleaseString(gitDirectory: String?) async throws -> String { + @Dependency(\.gitClient) var gitClient + + let preReleaseString: String + + if let branch { + preReleaseString = try await gitClient.version(branch: branch, gitDirectory: gitDirectory) + } else { + preReleaseString = try await gitClient.version(.init( + gitDirectory: gitDirectory, + style: .tag(exactMatch: false) + )).description + } + + if let prefix { + return "\(prefix)-\(preReleaseString)" + } + return preReleaseString + } +} + +@_spi(Internal) +public extension Configuration.SemVar { + + private func applyingPreRelease(_ semVar: SemVar, _ gitDirectory: String?) async throws -> SemVar { + guard let preReleaseStrategy = self.preRelease else { return semVar } + let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory) + return semVar.applyingPreRelease(preRelease) + } + + func currentVersion(file: URL, gitDirectory: String? = nil) async throws -> CurrentVersionContainer.Version { + @Dependency(\.fileClient) var fileClient + @Dependency(\.gitClient) var gitClient + + let fileOutput = try? await fileClient.semVar(file: file, gitDirectory: gitDirectory) + var semVar = fileOutput?.semVar + let usesOptionalType = fileOutput?.usesOptionalType + + if requireExistingFile { + guard let semVar else { + throw CliClientError.fileDoesNotExist(path: file.cleanFilePath) + } + return try await .semVar( + applyingPreRelease(semVar, gitDirectory), + usesOptionalType: usesOptionalType ?? false + ) + } + + // Didn't have existing semVar loaded from file, so check for git-tag. + + semVar = try await gitClient.version(.init( + gitDirectory: gitDirectory, + style: .tag(exactMatch: false) + )).semVar + + if requireExistingSemVar { + guard let semVar else { + fatalError() + } + return try await .semVar( + applyingPreRelease(semVar, gitDirectory), + usesOptionalType: usesOptionalType ?? false + ) + } + + return try await .semVar( + applyingPreRelease(.init(), gitDirectory), + usesOptionalType: usesOptionalType ?? false + ) + } +} + +extension Configuration.VersionStrategy { + + func currentVersion(targetUrl: URL, gitDirectory: String?) async throws -> CurrentVersionContainer { + @Dependency(\.gitClient) var gitClient + + guard let branch else { + guard let semvar else { + throw ConfigurationParsingError.versionStrategyError( + message: "Neither branch nor semvar set on configuration." + ) + } + return try await .init( + targetUrl: targetUrl, + version: semvar.currentVersion(file: targetUrl, gitDirectory: gitDirectory) + ) + } + return try await .init( + targetUrl: targetUrl, + version: .string( + gitClient.version(branch: branch, gitDirectory: gitDirectory) + ) + ) + } +} + +enum ConfigurationParsingError: Error { + case pathOrModuleNotSet + case versionStrategyError(message: String) +} diff --git a/Sources/CliClient/Internal/VersionStrategy+currentVersion.swift b/Sources/CliClient/Internal/VersionStrategy+currentVersion.swift index c74bdd6..8c4f602 100644 --- a/Sources/CliClient/Internal/VersionStrategy+currentVersion.swift +++ b/Sources/CliClient/Internal/VersionStrategy+currentVersion.swift @@ -1,3 +1,4 @@ +import ConfigurationClient import Dependencies import FileClient import struct Foundation.URL diff --git a/Sources/ConfigurationClient/Configuration.swift b/Sources/ConfigurationClient/Configuration.swift index 5c872a9..0e704ec 100644 --- a/Sources/ConfigurationClient/Configuration.swift +++ b/Sources/ConfigurationClient/Configuration.swift @@ -47,7 +47,7 @@ public extension Configuration { struct Branch: Codable, Equatable, Sendable { /// Include the commit sha in the output for this strategy. - let includeCommitSha: Bool + public let includeCommitSha: Bool /// Create a new branch strategy. /// @@ -217,10 +217,10 @@ public extension Configuration { struct VersionStrategy: Codable, Equatable, Sendable { /// Set if we're using the branch and commit sha to derive the version. - let branch: Branch? + public let branch: Branch? /// Set if we're using semvar to derive the version. - let semvar: SemVar? + public let semvar: SemVar? /// Create a new version strategy that uses branch and commit sha to derive the version. /// diff --git a/Sources/ConfigurationClient/ConfigurationClient.swift b/Sources/ConfigurationClient/ConfigurationClient.swift index c20ac99..1a783bc 100644 --- a/Sources/ConfigurationClient/ConfigurationClient.swift +++ b/Sources/ConfigurationClient/ConfigurationClient.swift @@ -17,13 +17,13 @@ public extension DependencyValues { public struct ConfigurationClient: Sendable { /// Find a configuration file in the given directory or in current working directory. - public var find: @Sendable (URL?) async throws -> ConfigruationFile? + public var find: @Sendable (URL?) async throws -> ConfigurationFile? /// Load a configuration file. - public var load: @Sendable (ConfigruationFile) async throws -> Configuration + public var load: @Sendable (ConfigurationFile) async throws -> Configuration /// Write a configuration file. - public var write: @Sendable (Configuration, ConfigruationFile) async throws -> Void + public var write: @Sendable (Configuration, ConfigurationFile) async throws -> Void /// Find a configuration file and load it if found. public func findAndLoad(_ url: URL? = nil) async throws -> Configuration { @@ -46,7 +46,7 @@ extension ConfigurationClient: DependencyKey { } } -private func findConfiguration(_ url: URL?) async throws -> ConfigruationFile? { +private func findConfiguration(_ url: URL?) async throws -> ConfigurationFile? { @Dependency(\.fileClient) var fileClient var url: URL! = url @@ -55,8 +55,10 @@ private func findConfiguration(_ url: URL?) async throws -> ConfigruationFile? { } // Check if url is a valid configuration url. - var configurationFile = ConfigruationFile(url: url) - if let configurationFile { return configurationFile } + var configurationFile = ConfigurationFile(url: url) + if let configurationFile, fileClient.fileExists(configurationFile.url) { + return configurationFile + } guard try await fileClient.isDirectory(url.cleanFilePath) else { throw ConfigurationClientError.invalidConfigurationDirectory(path: url.cleanFilePath) @@ -64,13 +66,17 @@ private func findConfiguration(_ url: URL?) async throws -> ConfigruationFile? { // Check for toml file. let tomlUrl = url.appending(path: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).toml") - configurationFile = ConfigruationFile(url: tomlUrl) - if let configurationFile { return configurationFile } + configurationFile = ConfigurationFile(url: tomlUrl) + if let configurationFile, fileClient.fileExists(configurationFile.url) { + return configurationFile + } // Check for json file. let jsonUrl = url.appending(path: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).json") - configurationFile = ConfigruationFile(url: jsonUrl) - if let configurationFile { return configurationFile } + configurationFile = ConfigurationFile(url: jsonUrl) + if let configurationFile, fileClient.fileExists(configurationFile.url) { + return configurationFile + } // Couldn't find valid configuration file. return nil diff --git a/Sources/ConfigurationClient/ConfigurationFile.swift b/Sources/ConfigurationClient/ConfigurationFile.swift index 09d93b2..5a41820 100644 --- a/Sources/ConfigurationClient/ConfigurationFile.swift +++ b/Sources/ConfigurationClient/ConfigurationFile.swift @@ -3,7 +3,7 @@ import FileClient import Foundation /// Represents a configuration file type and location. -public enum ConfigruationFile: Equatable, Sendable { +public enum ConfigurationFile: Equatable, Sendable { /// A json configuration file. case json(URL) @@ -34,7 +34,7 @@ public enum ConfigruationFile: Equatable, Sendable { } /// The url of the file. - var url: URL { + public var url: URL { switch self { case let .json(url): return url case let .toml(url): return url @@ -42,7 +42,7 @@ public enum ConfigruationFile: Equatable, Sendable { } } -extension ConfigruationFile { +extension ConfigurationFile { func load() async throws -> Configuration? { @Dependency(\.coders) var coders diff --git a/Sources/cli-version/CliVersionCommand.swift b/Sources/cli-version/CliVersionCommand.swift index 67723be..59f9883 100644 --- a/Sources/cli-version/CliVersionCommand.swift +++ b/Sources/cli-version/CliVersionCommand.swift @@ -9,7 +9,8 @@ struct CliVersionCommand: AsyncParsableCommand { subcommands: [ Build.self, Bump.self, - Generate.self + Generate.self, + UtilsCommand.self ], defaultSubcommand: Bump.self ) diff --git a/Sources/cli-version/GlobalOptions.swift b/Sources/cli-version/GlobalOptions.swift index 86c7172..38a1d7f 100644 --- a/Sources/cli-version/GlobalOptions.swift +++ b/Sources/cli-version/GlobalOptions.swift @@ -1,5 +1,6 @@ import ArgumentParser @_spi(Internal) import CliClient +import ConfigurationClient import Dependencies import Foundation import Rainbow @@ -173,6 +174,16 @@ private extension TargetOptions { } struct InvalidTargetOption: Error {} + + func configTarget() throws -> Configuration.Target? { + guard let path else { + guard let module else { + return nil + } + return .init(module: .init(module, fileName: fileName)) + } + return .init(path: path) + } } extension PreReleaseOptions { @@ -197,6 +208,25 @@ extension PreReleaseOptions { } } + func configPreReleaseStrategy() throws -> Configuration.PreReleaseStrategy? { + guard let custom else { + if useBranchAsPreRelease { + return .branch() + } else if useTagAsPreRelease { + return .gitTag + } else { + return nil + } + } + + if useBranchAsPreRelease { + return .customBranchPrefix(custom) + } else if useTagAsPreRelease { + return .customGitTagPrefix(custom) + } else { + return .custom(custom) + } + } } extension SemVarOptions { @@ -207,4 +237,13 @@ extension SemVarOptions { requireExistingSemVar: requireExistingSemvar ) } + + func configSemVarOptions() throws -> Configuration.SemVar { + try .init( + preRelease: preRelease.configPreReleaseStrategy(), + requireExistingFile: requireExistingFile, + requireExistingSemVar: requireExistingSemvar + ) + } + } diff --git a/Sources/cli-version/UtilsCommand.swift b/Sources/cli-version/UtilsCommand.swift new file mode 100644 index 0000000..66ae26b --- /dev/null +++ b/Sources/cli-version/UtilsCommand.swift @@ -0,0 +1,48 @@ +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 + ] + ) +} + +extension UtilsCommand { + struct DumpConfig: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "dump-config", + abstract: "Show the parsed configuration." + ) + + @Argument( + help: """ + Optional path to the configuration file, if not supplied will search the current directory + """, + completion: .file(extensions: ["toml", "json"]) + ) + var file: String? + + func run() async throws { + try await withDependencies { + $0.fileClient = .liveValue + $0.configurationClient = .liveValue + } operation: { + @Dependency(\.configurationClient) var configurationClient + + let configuration = try await configurationClient.findAndLoad( + file != nil ? URL(filePath: file!) : nil + ) + + customDump(configuration) + } + } + } +} diff --git a/Tests/CliVersionTests/CliVersionTests.swift b/Tests/CliVersionTests/CliVersionTests.swift index 616ab98..192d5b3 100644 --- a/Tests/CliVersionTests/CliVersionTests.swift +++ b/Tests/CliVersionTests/CliVersionTests.swift @@ -29,16 +29,16 @@ final class GitVersionTests: XCTestCase { .cleanFilePath } - #if !os(Linux) - func test_live() async throws { - @Dependency(\.gitClient) var versionClient: GitClient - - let version = try await versionClient.currentVersion(in: gitDir) - print("VERSION: \(version)") - // can't really have a predictable result for the live client. - XCTAssertNotEqual(version, "blob") - } - #endif + // #if !os(Linux) + // func test_live() async throws { + // @Dependency(\.gitClient) var versionClient: GitClient + // + // let version = try await versionClient.currentVersion(in: gitDir) + // print("VERSION: \(version)") + // // can't really have a predictable result for the live client. + // XCTAssertNotEqual(version, "blob") + // } + // #endif func test_file_client() async throws { try await withTemporaryDirectory { tmpDir in diff --git a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift index aeab0e0..c620609 100644 --- a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift +++ b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift @@ -2,6 +2,7 @@ import ConfigurationClient import Dependencies import Foundation import Testing +import TestSupport @Suite("ConfigurationClientTests") struct ConfigurationClientTests { @@ -30,7 +31,7 @@ struct ConfigurationClientTests { @Test(arguments: ["foo", ".foo"]) func configurationFile(fileName: String) { for ext in ["toml", "json", "bar"] { - let file = ConfigruationFile(url: URL(filePath: "\(fileName).\(ext)")) + let file = ConfigurationFile(url: URL(filePath: "\(fileName).\(ext)")) switch ext { case "toml": #expect(file == .toml(URL(filePath: "\(fileName).toml"))) @@ -42,6 +43,61 @@ struct ConfigurationClientTests { } } + @Test + func writeAndLoad() async throws { + try await withTemporaryDirectory { url in + try await run { + @Dependency(\.configurationClient) var configurationClient + + for ext in ["toml", "json"] { + let fileUrl = url.appending(path: "test.\(ext)") + let configuration = Configuration.mock + let configurationFile = ConfigurationFile(url: fileUrl)! + + try await configurationClient.write(configuration, configurationFile) + let loaded = try await configurationClient.load(configurationFile) + #expect(loaded == configuration) + + let findAndLoaded = try await configurationClient.findAndLoad(configurationFile.url) + #expect(findAndLoaded == configuration) + + try FileManager.default.removeItem(at: fileUrl) + } + } + } + } + + @Test + func findAndLoad() async throws { + try await withTemporaryDirectory { url in + try await run { + @Dependency(\.configurationClient) var configurationClient + + let shouldBeNil = try await configurationClient.find(url) + #expect(shouldBeNil == nil) + + do { + _ = try await configurationClient.findAndLoad(url) + #expect(Bool(false)) + } catch { + #expect(Bool(true)) + } + + for ext in ["toml", "json"] { + let fileUrl = url.appending(path: ".bump-version.\(ext)") + let configuration = Configuration.mock + let configurationFile = ConfigurationFile(url: fileUrl)! + + try await configurationClient.write(configuration, configurationFile) + let loaded = try await configurationClient.findAndLoad(url) + #expect(loaded == configuration) + + try FileManager.default.removeItem(at: fileUrl) + } + } + } + } + func run( setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, operation: () async throws -> Void