From 85b285347bf679a584175d822793310c2d91dcb6 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Mon, 16 Dec 2024 17:14:25 -0500 Subject: [PATCH] feat: Removes cli-client --- Package.swift | 37 +- Sources/CliClient/CliClient+Commands.swift | 189 ---------- .../CliClient/CliClient+PandocCommands.swift | 223 ------------ Sources/CliClient/CliClient+RunPlaybook.swift | 216 ------------ Sources/CliClient/CliClientError.swift | 16 - Sources/CliClient/Constants.swift | 11 - Sources/CliClient/GenerateJson.swift | 85 ----- Sources/CliClient/Interface.swift | 329 ----------------- Sources/CliClient/LoggingExtensions.swift | 29 -- .../ConfigurationClient.swift | 59 +++- Sources/ConfigurationClient/Constants.swift | 1 + Sources/PandocClient/Constants.swift | 7 + Sources/PandocClient/PandocClient+Run.swift | 241 +++++++++++++ Sources/PandocClient/PandocClient.swift | 95 +++++ Sources/hpa/BuildCommand.swift | 1 - Sources/hpa/CreateCommand.swift | 12 - .../GenerateHtmlCommand.swift | 10 +- .../GenerateLatexCommand.swift | 9 +- .../GenerateCommands/GenerateOptions.swift | 22 +- .../GenerateCommands/GeneratePdfCommand.swift | 10 +- Sources/hpa/Internal/Constants.swift | 3 + Sources/hpa/Internal/GlobalOptions.swift | 26 +- .../hpa/Internal/VaultOptions+Globals.swift | 35 +- .../UtilsCommands/GenerateConfigCommand.swift | 32 +- .../GenerateProjectTemplateCommand.swift | 1 - .../InstallDependenciesCommand.swift | 17 +- .../hpa/VaultCommands/DecryptCommand.swift | 1 - .../hpa/VaultCommands/EncryptCommand.swift | 1 - Sources/hpa/VaultCommands/VaultOptions.swift | 8 +- Tests/CliClientTests/CliClientTests.swift | 333 ------------------ .../ConfigurationClientTests.swift | 25 +- .../PandocClientTests/PandocClientTests.swift | 333 ++++++++++++++++++ 32 files changed, 837 insertions(+), 1580 deletions(-) delete mode 100644 Sources/CliClient/CliClient+Commands.swift delete mode 100644 Sources/CliClient/CliClient+PandocCommands.swift delete mode 100644 Sources/CliClient/CliClient+RunPlaybook.swift delete mode 100644 Sources/CliClient/CliClientError.swift delete mode 100644 Sources/CliClient/Constants.swift delete mode 100644 Sources/CliClient/GenerateJson.swift delete mode 100644 Sources/CliClient/Interface.swift delete mode 100644 Sources/CliClient/LoggingExtensions.swift create mode 100644 Sources/PandocClient/Constants.swift create mode 100644 Sources/PandocClient/PandocClient+Run.swift create mode 100644 Sources/PandocClient/PandocClient.swift delete mode 100644 Tests/CliClientTests/CliClientTests.swift create mode 100644 Tests/PandocClientTests/PandocClientTests.swift diff --git a/Package.swift b/Package.swift index 123de6f..a484696 100644 --- a/Package.swift +++ b/Package.swift @@ -7,12 +7,12 @@ let package = Package( platforms: [.macOS(.v14)], products: [ .executable(name: "hpa", targets: ["hpa"]), - .library(name: "CliClient", targets: ["CliClient"]), .library(name: "CodersClient", targets: ["CodersClient"]), .library(name: "CommandClient", targets: ["CommandClient"]), .library(name: "ConfigurationClient", targets: ["ConfigurationClient"]), .library(name: "FileClient", targets: ["FileClient"]), .library(name: "PlaybookClient", targets: ["PlaybookClient"]), + .library(name: "PandocClient", targets: ["PandocClient"]), .library(name: "VaultClient", targets: ["VaultClient"]) ], dependencies: [ @@ -27,9 +27,9 @@ let package = Package( .executableTarget( name: "hpa", dependencies: [ - "CliClient", "ConfigurationClient", "FileClient", + "PandocClient", "PlaybookClient", "VaultClient", .product(name: "ArgumentParser", package: "swift-argument-parser"), @@ -38,25 +38,6 @@ let package = Package( .product(name: "ShellClient", package: "swift-shell-client") ] ), - .target( - name: "CliClient", - dependencies: [ - "CommandClient", - "CodersClient", - "ConfigurationClient", - "PlaybookClient", - .product(name: "Dependencies", package: "swift-dependencies"), - .product(name: "DependenciesMacros", package: "swift-dependencies"), - .product(name: "ShellClient", package: "swift-shell-client") - ] - ), - .testTarget( - name: "CliClientTests", - dependencies: [ - "CliClient", - "TestSupport" - ] - ), .target( name: "CodersClient", dependencies: [ @@ -110,6 +91,20 @@ let package = Package( .product(name: "DependenciesMacros", package: "swift-dependencies") ] ), + .target( + name: "PandocClient", + dependencies: [ + "CommandClient", + "ConfigurationClient", + "PlaybookClient", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies") + ] + ), + .testTarget( + name: "PandocClientTests", + dependencies: ["PandocClient", "TestSupport"] + ), .target( name: "PlaybookClient", dependencies: [ diff --git a/Sources/CliClient/CliClient+Commands.swift b/Sources/CliClient/CliClient+Commands.swift deleted file mode 100644 index b569f0b..0000000 --- a/Sources/CliClient/CliClient+Commands.swift +++ /dev/null @@ -1,189 +0,0 @@ -import ConfigurationClient -import Dependencies -import DependenciesMacros -import FileClient -import Foundation -import PlaybookClient -import ShellClient - -public extension CliClient { - - func runCommand( - quiet: Bool, - shell: ShellCommand.Shell, - _ args: [String] - ) async throws { - try await runCommand(.init(arguments: args, quiet: quiet, shell: shell)) - } - - func runCommand( - quiet: Bool, - shell: ShellCommand.Shell, - _ args: String... - ) async throws { - try await runCommand(quiet: quiet, shell: shell, args) - } - - func installDependencies( - quiet: Bool = false, - shell: String? = nil, - extraArgs: [String]? = nil - ) async throws { - @Dependency(\.playbookClient) var playbookClient - @Dependency(\.configurationClient) var configurationClient - - var arguments = [ - "brew", "install" - ] + Constants.brewPackages - - if let extraArgs { - arguments.append(contentsOf: extraArgs) - } - - try await runCommand( - quiet: quiet, - shell: shell.orDefault, - arguments - ) - - let configuration = try await configurationClient.findAndLoad() - try await playbookClient.repository.install(configuration) - } - - func runPlaybookCommand( - _ options: PlaybookOptions, - logging loggingOptions: LoggingOptions - ) async throws { - try await withLogger(loggingOptions) { - @Dependency(\.configurationClient) var configurationClient - @Dependency(\.logger) var logger - @Dependency(\.playbookClient) var playbookClient - - let configuration = try await configurationClient.ensuredConfiguration(options.configuration) - logger.trace("Configuration: \(configuration)") - - let playbookDirectory = try await playbookClient.repository.directory(configuration) - let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)" - logger.trace("Playbook path: \(playbookPath)") - - let inventoryPath = ensuredInventoryPath( - options.inventoryFilePath, - configuration: configuration, - playbookDirectory: playbookDirectory - ) - logger.trace("Inventory path: \(inventoryPath)") - - var arguments = [ - Constants.playbookCommand, playbookPath, - "--inventory", inventoryPath - ] + options.arguments - - if let defaultArgs = configuration.args { - arguments.append(contentsOf: defaultArgs) - } - - if configuration.useVaultArgs, let vaultArgs = configuration.vault.args { - arguments.append(contentsOf: vaultArgs) - } - - logger.trace("Running playbook command with arguments: \(arguments)") - - try await runCommand( - quiet: options.quiet, - shell: options.shell.orDefault, - arguments - ) - } - } - - func runVaultCommand( - _ options: VaultOptions, - logging loggingOptions: LoggingOptions - ) async throws { - try await withLogger(loggingOptions) { - @Dependency(\.configurationClient) var configurationClient - @Dependency(\.fileClient) var fileClient - @Dependency(\.logger) var logger - - let configuration = try await configurationClient.ensuredConfiguration(options.configuration) - logger.trace("Configuration: \(configuration)") - - let vaultFilePath = try await fileClient.ensuredVaultFilePath(options.vaultFilePath) - logger.trace("Vault file: \(vaultFilePath)") - - var arguments = [ - Constants.vaultCommand - ] + options.arguments - - if let defaultArgs = configuration.vault.args { - arguments.append(contentsOf: defaultArgs) - } - - if arguments.contains("encrypt"), - !arguments.contains("--encrypt-vault-id"), - let id = configuration.vault.encryptId - { - arguments.append(contentsOf: ["--encrypt-vault-id", id]) - } - - arguments.append(vaultFilePath) - - logger.trace("Running vault command with arguments: \(arguments)") - - try await runCommand( - quiet: options.quiet, - shell: options.shell.orDefault, - arguments - ) - } - } -} - -@_spi(Internal) -public extension ConfigurationClient { - func ensuredConfiguration(_ optionalConfig: Configuration?) async throws -> Configuration { - guard let config = optionalConfig else { - return try await findAndLoad() - } - return config - } -} - -@_spi(Internal) -public extension Optional where Wrapped == String { - var orDefault: ShellCommand.Shell { - guard let shell = self else { return .zsh(useDashC: true) } - return .custom(path: shell, useDashC: true) - } -} - -@_spi(Internal) -public func ensuredInventoryPath( - _ optionalInventoryPath: String?, - configuration: Configuration, - playbookDirectory: String -) -> String { - guard let path = optionalInventoryPath else { - guard let path = configuration.playbook?.inventory else { - return "\(playbookDirectory)/\(Constants.inventoryFileName)" - } - return path - } - return path -} - -@_spi(Internal) -public extension FileClient { - - func ensuredVaultFilePath(_ optionalPath: String?) async throws -> String { - guard let path = optionalPath else { - guard let url = try await findVaultFileInCurrentDirectory() else { - throw CliClientError.vaultFileNotFound - } - return url.cleanFilePath - } - return path - } -} - -extension ShellCommand.Shell: @retroactive @unchecked Sendable {} diff --git a/Sources/CliClient/CliClient+PandocCommands.swift b/Sources/CliClient/CliClient+PandocCommands.swift deleted file mode 100644 index 1d2a249..0000000 --- a/Sources/CliClient/CliClient+PandocCommands.swift +++ /dev/null @@ -1,223 +0,0 @@ -import ConfigurationClient -import Dependencies -import Foundation -import ShellClient - -// TODO: Need to parse / ensure that header includes and files are in the build directory, not sure -// exactly how to handle if they're not, but it seems reasonable to potentially allow files that are -// outside of the build directory to be included. - -public extension CliClient { - - @discardableResult - func runPandocCommand( - _ options: PandocOptions, - logging loggingOptions: LoggingOptions - ) async throws -> String { - try await withLogger(loggingOptions) { - @Dependency(\.configurationClient) var configurationClient - @Dependency(\.logger) var logger - - let configuration = try await configurationClient.findAndLoad() - logger.trace("Configuration: \(configuration)") - - let ensuredOptions = try await ensurePandocOptions( - configuration: configuration, - options: options - ) - - let projectDirectory = options.projectDirectory ?? ProcessInfo.processInfo.environment["PWD"] - guard let projectDirectory else { - throw CliClientError.generate(.projectDirectoryNotSpecified) - } - - let outputDirectory = options.outputDirectory ?? projectDirectory - let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)" - - var arguments = [ - "pandoc" - ] - arguments += ensuredOptions.includeInHeader.map { - "--include-in-header=\(projectDirectory)/\(ensuredOptions.buildDirectory)/\($0)" - } - - if let pdfEngine = ensuredOptions.pdfEngine { - arguments.append("--pdf-engine=\(pdfEngine)") - } - - arguments.append("--output=\(outputPath)") - arguments += ensuredOptions.files.map { - "\(projectDirectory)/\(ensuredOptions.buildDirectory)/\($0)" - } - - if options.shouldBuild { - logger.trace("Building project...") - try await runPlaybookCommand( - .init( - arguments: [ - "--tags", "build-project", - "--extra-vars", "project_dir=\(projectDirectory)" - ], - configuration: configuration, - quiet: options.quiet, - shell: options.shell - ), - logging: loggingOptions - ) - } - - logger.trace("Running pandoc with arguments: \(arguments)") - - try await runCommand( - quiet: options.quiet, - shell: options.shell.orDefault, - arguments - ) - - return outputPath - } - } -} - -@_spi(Internal) -public struct EnsuredPandocOptions: Equatable, Sendable { - public let buildDirectory: String - public let files: [String] - public let includeInHeader: [String] - public let outputFileName: String - public let outputFileType: CliClient.PandocOptions.FileType - public let pdfEngine: String? - - public var ensuredExtensionFileName: String { - let extensionString: String - - switch outputFileType { - case .html: - extensionString = ".html" - case .latex: - extensionString = ".tex" - case .pdf: - extensionString = ".pdf" - } - - if !outputFileName.hasSuffix(extensionString) { - return outputFileName + extensionString - } - return outputFileName - } -} - -@_spi(Internal) -public func ensurePandocOptions( - configuration: Configuration, - options: CliClient.PandocOptions -) async throws -> EnsuredPandocOptions { - let defaults = Configuration.Generate.default - let pdfEngine = parsePdfEngine(configuration.generate, defaults, options) - - return .init( - buildDirectory: parseBuildDirectory(configuration.generate, defaults, options), - files: parseFiles(configuration.generate, defaults, options), - includeInHeader: parseIncludeInHeader(configuration.generate, defaults, options), - outputFileName: parseOutputFileName(configuration.generate, defaults, options), - outputFileType: options.outputFileType, - pdfEngine: pdfEngine - ) -} - -private func parsePdfEngine( - _ configuration: Configuration.Generate?, - _ defaults: Configuration.Generate, - _ options: CliClient.PandocOptions -) -> String? { - switch options.outputFileType { - case .html, .latex: - return nil - case let .pdf(engine: engine): - if let engine { - return engine - } else if let engine = configuration?.pdfEngine { - return engine - } else if let engine = defaults.pdfEngine { - return engine - } else { - return "xelatex" - } - } -} - -private func parseFiles( - _ configuration: Configuration.Generate?, - _ defaults: Configuration.Generate, - _ options: CliClient.PandocOptions -) -> [String] { - @Dependency(\.logger) var logger - - if let files = options.files { - return files - } else if let files = configuration?.files { - return files - } else if let files = defaults.files { - return files - } else { - logger.warning("Files not specified, this could lead to errors.") - return [] - } -} - -private func parseIncludeInHeader( - _ configuration: Configuration.Generate?, - _ defaults: Configuration.Generate, - _ options: CliClient.PandocOptions -) -> [String] { - @Dependency(\.logger) var logger - - if let files = options.includeInHeader { - return files - } else if let files = configuration?.includeInHeader { - return files - } else if let files = defaults.includeInHeader { - return files - } else { - logger.warning("Include in header files not specified, this could lead to errors.") - return [] - } -} - -private func parseOutputFileName( - _ configuration: Configuration.Generate?, - _ defaults: Configuration.Generate, - _ options: CliClient.PandocOptions -) -> String { - @Dependency(\.logger) var logger - - if let output = options.outputFileName { - return output - } else if let output = configuration?.outputFileName { - return output - } else if let output = defaults.outputFileName { - return output - } else { - logger.warning("Output file name not specified, this could lead to errors.") - return "Report" - } -} - -private func parseBuildDirectory( - _ configuration: Configuration.Generate?, - _ defaults: Configuration.Generate, - _ options: CliClient.PandocOptions -) -> String { - @Dependency(\.logger) var logger - - if let output = options.buildDirectory { - return output - } else if let output = configuration?.buildDirectory { - return output - } else if let output = defaults.buildDirectory { - return output - } else { - logger.warning("Output file name not specified, this could lead to errors.") - return ".build" - } -} diff --git a/Sources/CliClient/CliClient+RunPlaybook.swift b/Sources/CliClient/CliClient+RunPlaybook.swift deleted file mode 100644 index e66371f..0000000 --- a/Sources/CliClient/CliClient+RunPlaybook.swift +++ /dev/null @@ -1,216 +0,0 @@ -import ConfigurationClient -import Dependencies -import Foundation -import PlaybookClient - -extension CliClient.RunPlaybook { - - static func makeCommonArguments( - configuration: Configuration, - inventoryFilePath: String? - ) async throws -> [String] { - @Dependency(\.logger) var logger - @Dependency(\.playbookClient) var playbookClient - - let playbookDirectory = try await playbookClient.repository.directory(configuration) - let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)" - logger.trace("Playbook path: \(playbookPath)") - - let inventoryPath = ensuredInventoryPath( - inventoryFilePath, - configuration: configuration, - playbookDirectory: playbookDirectory - ) - logger.trace("Inventory path: \(inventoryPath)") - - var arguments = [ - Constants.playbookCommand, playbookPath, - "--inventory", inventoryPath - ] - - if let defaultArgs = configuration.args { - arguments.append(contentsOf: defaultArgs) - } - - if configuration.useVaultArgs, let vaultArgs = configuration.vault.args { - arguments.append(contentsOf: vaultArgs) - } - - logger.trace("Common arguments: \(inventoryPath)") - return arguments - } - -} - -extension CliClient.RunPlaybook.BuildOptions { - private func applyArguments( - to arguments: inout [String], - configuration: Configuration - ) throws { - let projectDirectory = projectDirectory - ?? ProcessInfo.processInfo.environment["PWD"] - - guard let projectDirectory else { - throw CliClientError.projectDirectoryNotFound - } - - arguments.append(contentsOf: [ - "--tags", "build-project", - "--extra-vars", "project_dir=\(projectDirectory)" - ]) - - if let extraOptions = extraOptions { - arguments.append(contentsOf: extraOptions) - } - } -} - -extension CliClient.RunPlaybook.CreateOptions { - private func applyArguments( - to arguments: inout [String], - configuration: Configuration - ) throws { - let json = try createJSONData(configuration: configuration) - - arguments.append(contentsOf: [ - "--tags", "setup-project", - "--extra-vars", "project_dir=\(projectDirectory)", - "--extra-vars", "'\(json)'" - ]) - - if let extraOptions { - arguments.append(contentsOf: extraOptions) - } - } - -} - -extension CliClient.PlaybookOptions.Route { - - private func parseInventoryPath( - _ configuration: Configuration, - _ playbookDirectory: String - ) -> String { - let inventoryFilePath: String? - - switch self { - case let .build(options): - inventoryFilePath = options.inventoryFilePath - case let .create(options): - inventoryFilePath = options.inventoryFilePath - } - - return ensuredInventoryPath( - inventoryFilePath, - configuration: configuration, - playbookDirectory: playbookDirectory - ) - } - - func makeArguments(configuration: Configuration) async throws -> [String] { - @Dependency(\.logger) var logger - @Dependency(\.playbookClient) var playbookClient - - let playbookDirectory = try await playbookClient.repository.directory(configuration) - let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)" - logger.trace("Playbook path: \(playbookPath)") - - let inventoryPath = parseInventoryPath(configuration, playbookDirectory) - logger.trace("Inventory path: \(inventoryPath)") - - var arguments = [ - Constants.playbookCommand, playbookPath, - "--inventory", inventoryPath - ] - - if let defaultArgs = configuration.args { - arguments.append(contentsOf: defaultArgs) - } - - if configuration.useVaultArgs, let vaultArgs = configuration.vault.args { - arguments.append(contentsOf: vaultArgs) - } - - // try applyArguments(to: &arguments, configuration: configuration) - - return arguments - } -} - -// NOTE: We're not using the `Coders` client because we generally do not -// want the output to be `prettyPrinted` or anything, unless we're running -// tests, so we use a supplied json encoder. - -extension CliClient.RunPlaybook.CreateOptions { - - func createJSONData( - configuration: Configuration, - encoder: JSONEncoder = .init() - ) throws -> Data { - @Dependency(\.logger) var logger - - let templateDir = template.directory ?? configuration.template.directory - let templateRepo = template.url ?? configuration.template.url - let version = template.version ?? configuration.template.version - - logger.debug(""" - (\(useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo))) - """) - - switch (useLocalTemplateDirectory, templateDir, templateRepo) { - case (true, .none, _): - // User supplied they wanted to use a local template directory, but we could not find - // the path set from command line or in configuration. - throw CliClientError.templateDirectoryNotFound - case let (false, _, .some(repo)): - // User did not supply they wanted to use a local template directory, and we found a repo url that was - // either set by the command line or found in the configuration. - logger.debug("Using repo.") - return try encoder.encode(TemplateRepo(repo: repo, version: version)) - case let (true, .some(templateDir), _): - // User supplied they wanted to use a local template directory, and we found the template directory - // either set by the command line or in the configuration. - logger.debug("Using template directory.") - return try encoder.encode(TemplateDirJson(path: templateDir)) - case let (false, .some(templateDir), _): - // User supplied they did not wanted to use a local template directory, and we found the template directory - // either set by the command line or in the configuration, and no repo was found / handled previously. - logger.debug("Using template directory.") - return try encoder.encode(TemplateDirJson(path: templateDir)) - case (_, .none, .none): - // We could not find a repo or template directory. - throw CliClientError.templateDirectoryOrRepoNotSpecified - } - } -} - -private struct TemplateDirJson: Encodable { - - let template: Template - - init(path: String) { - self.template = .init(path: path) - } - - struct Template: Encodable { - let path: String - } -} - -private struct TemplateRepo: Encodable { - - let template: Template - - init(repo: String, version: String?) { - self.template = .init(repo: .init(url: repo, version: version ?? "main")) - } - - struct Template: Encodable { - let repo: Repo - } - - struct Repo: Encodable { - let url: String - let version: String - } -} diff --git a/Sources/CliClient/CliClientError.swift b/Sources/CliClient/CliClientError.swift deleted file mode 100644 index 6af8349..0000000 --- a/Sources/CliClient/CliClientError.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -public enum CliClientError: Error { - case brewfileNotFound - case encodingError - case playbookDirectoryNotFound - case projectDirectoryNotFound - case generate(GenerateError) - case templateDirectoryNotFound - case templateDirectoryOrRepoNotSpecified - case vaultFileNotFound - - public enum GenerateError: Sendable { - case projectDirectoryNotSpecified - } -} diff --git a/Sources/CliClient/Constants.swift b/Sources/CliClient/Constants.swift deleted file mode 100644 index 4e7b47d..0000000 --- a/Sources/CliClient/Constants.swift +++ /dev/null @@ -1,11 +0,0 @@ -enum Constants { - static let executableName = "hpa" - static let playbookBundleDirectoryName = "ansible-hpa-playbook" - static let playbookCommand = "ansible-playbook" - static let playbookFileName = "main.yml" - static let inventoryFileName = "inventory.ini" - static let vaultCommand = "ansible-vault" - static let brewPackages = [ - "ansible", "imagemagick", "pandoc", "texLive" - ] -} diff --git a/Sources/CliClient/GenerateJson.swift b/Sources/CliClient/GenerateJson.swift deleted file mode 100644 index b9e062c..0000000 --- a/Sources/CliClient/GenerateJson.swift +++ /dev/null @@ -1,85 +0,0 @@ -import ConfigurationClient -import Dependencies -import Foundation - -// NOTE: We're not using the `Coders` client because we generally do not -// want the output to be `prettyPrinted` or anything, unless we're running -// tests, so we use a supplied json encoder. - -// TODO: Remove. -func createJSONData( - _ options: CliClient.GenerateJsonOptions, - logging loggingOptions: CliClient.LoggingOptions, - encoder: JSONEncoder = .init() -) async throws -> Data { - try await CliClient.withLogger(loggingOptions) { - @Dependency(\.logger) var logger - @Dependency(\.configurationClient) var configurationClient - - let configuration = try await configurationClient.findAndLoad() - - let templateDir = options.templateDirectory ?? configuration.template.directory - let templateRepo = options.templateRepo ?? configuration.template.url - let version = options.version ?? configuration.template.version - - logger.debug(""" - (\(options.useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo))) - """) - - switch (options.useLocalTemplateDirectory, templateDir, templateRepo) { - case (true, .none, _): - // User supplied they wanted to use a local template directory, but we could not find - // the path set from command line or in configuration. - throw CliClientError.templateDirectoryNotFound - case let (false, _, .some(repo)): - // User did not supply they wanted to use a local template directory, and we found a repo url that was - // either set by the command line or found in the configuration. - logger.debug("Using repo.") - return try encoder.encode(TemplateRepo(repo: repo, version: version)) - case let (true, .some(templateDir), _): - // User supplied they wanted to use a local template directory, and we found the template directory - // either set by the command line or in the configuration. - logger.debug("Using template directory.") - return try encoder.encode(TemplateDirJson(path: templateDir)) - case let (false, .some(templateDir), _): - // User supplied they did not wanted to use a local template directory, and we found the template directory - // either set by the command line or in the configuration, and no repo was found / handled previously. - logger.debug("Using template directory.") - return try encoder.encode(TemplateDirJson(path: templateDir)) - case (_, .none, .none): - // We could not find a repo or template directory. - throw CliClientError.templateDirectoryOrRepoNotSpecified - } - } -} - -private struct TemplateDirJson: Encodable { - - let template: Template - - init(path: String) { - self.template = .init(path: path) - } - - struct Template: Encodable { - let path: String - } -} - -private struct TemplateRepo: Encodable { - - let template: Template - - init(repo: String, version: String?) { - self.template = .init(repo: .init(url: repo, version: version ?? "main")) - } - - struct Template: Encodable { - let repo: Repo - } - - struct Repo: Encodable { - let url: String - let version: String - } -} diff --git a/Sources/CliClient/Interface.swift b/Sources/CliClient/Interface.swift deleted file mode 100644 index b3ebdc6..0000000 --- a/Sources/CliClient/Interface.swift +++ /dev/null @@ -1,329 +0,0 @@ -import ConfigurationClient -import Dependencies -import DependenciesMacros -import Foundation -import ShellClient - -public extension DependencyValues { - var cliClient: CliClient { - get { self[CliClient.self] } - set { self[CliClient.self] = newValue } - } -} - -@DependencyClient -public struct CliClient: Sendable { - public var runCommand: @Sendable (RunCommandOptions) async throws -> Void - public var generateJSON: @Sendable (GenerateJsonOptions, LoggingOptions, JSONEncoder) async throws -> String - - public func generateJSON( - _ options: GenerateJsonOptions, - logging loggingOptions: LoggingOptions, - encoder jsonEncoder: JSONEncoder = .init() - ) async throws -> String { - try await generateJSON(options, loggingOptions, jsonEncoder) - } -} - -public extension CliClient { - - @DependencyClient - struct RunPlaybook: Sendable { - public var buildProject: @Sendable (RunOptions, BuildOptions) async throws -> Void - public var createProject: @Sendable (RunOptions, CreateOptions) async throws -> String - - public struct RunOptions: Equatable, Sendable { - public let loggingOptions: CliClient.LoggingOptions - public let quiet: Bool - public let shell: String? - } - - public struct BuildOptions: Equatable, Sendable { - public let extraOptions: [String]? - public let inventoryFilePath: String? - public let projectDirectory: String? - - public init( - extraOptions: [String]?, - inventoryFilePath: String?, - projectDirectory: String - ) { - self.extraOptions = extraOptions - self.inventoryFilePath = inventoryFilePath - self.projectDirectory = projectDirectory - } - - } - - public struct CreateOptions: Equatable, Sendable { - public let extraOptions: [String]? - public let inventoryFilePath: String? - public let projectDirectory: String - public let template: Configuration.Template - public let useLocalTemplateDirectory: Bool - - public init( - extraOptions: [String]?, - inventoryFilePath: String?, - projectDirectory: String, - template: Configuration.Template, - useLocalTemplateDirectory: Bool - ) { - self.extraOptions = extraOptions - self.inventoryFilePath = inventoryFilePath - self.projectDirectory = projectDirectory - self.template = template - self.useLocalTemplateDirectory = useLocalTemplateDirectory - } - } - } -} - -public extension CliClient { - - struct PandocOptions: Equatable, Sendable { - - let buildDirectory: String? - let files: [String]? - let includeInHeader: [String]? - let outputDirectory: String? - let outputFileName: String? - let outputFileType: FileType - let projectDirectory: String? - let quiet: Bool - let shell: String? - let shouldBuild: Bool - - public init( - buildDirectory: String? = nil, - files: [String]? = nil, - includeInHeader: [String]? = nil, - outputDirectory: String? = nil, - outputFileName: String? = nil, - outputFileType: FileType, - projectDirectory: String?, - quiet: Bool, - shell: String? = nil, - shouldBuild: Bool - ) { - self.buildDirectory = buildDirectory - self.files = files - self.includeInHeader = includeInHeader - self.outputDirectory = outputDirectory - self.outputFileName = outputFileName - self.outputFileType = outputFileType - self.projectDirectory = projectDirectory - self.quiet = quiet - self.shell = shell - self.shouldBuild = shouldBuild - } - - // swiftlint:disable nesting - public enum FileType: Equatable, Sendable { - case html - case latex - case pdf(engine: String?) - } - // swiftlint:enable nesting - } - - struct GenerateJsonOptions: Equatable, Sendable { - let templateDirectory: String? - let templateRepo: String? - let version: String? - let useLocalTemplateDirectory: Bool - - public init( - templateDirectory: String?, - templateRepo: String?, - version: String?, - useLocalTemplateDirectory: Bool - ) { - self.templateDirectory = templateDirectory - self.templateRepo = templateRepo - self.version = version - self.useLocalTemplateDirectory = useLocalTemplateDirectory - } - } - - struct LoggingOptions: Equatable, Sendable { - let commandName: String - let logLevel: Logger.Level - - public init(commandName: String, logLevel: Logger.Level) { - self.commandName = commandName - self.logLevel = logLevel - } - } - - struct PlaybookOptions: Sendable, Equatable { - let arguments: [String] - let configuration: Configuration? - let inventoryFilePath: String? - let playbookDirectory: String? - let quiet: Bool - let shell: String? - - public init( - arguments: [String], - configuration: Configuration? = nil, - inventoryFilePath: String? = nil, - playbookDirectory: String? = nil, - quiet: Bool, - shell: String? = nil - ) { - self.arguments = arguments - self.configuration = configuration - self.inventoryFilePath = inventoryFilePath - self.playbookDirectory = playbookDirectory - self.quiet = quiet - self.shell = shell - } - - public enum Route { - case build(BuildOption) - case create(CreateOption) - - public struct BuildOption: Equatable, Sendable { - public let extraOptions: [String]? - public let inventoryFilePath: String? - public let projectDirectory: String? - - public init( - extraOptions: [String]?, - inventoryFilePath: String?, - projectDirectory: String - ) { - self.extraOptions = extraOptions - self.inventoryFilePath = inventoryFilePath - self.projectDirectory = projectDirectory - } - - } - - public struct CreateOption: Equatable, Sendable { - public let extraOptions: [String]? - public let inventoryFilePath: String? - public let projectDirectory: String - public let template: Configuration.Template - public let useLocalTemplateDirectory: Bool - - public init( - extraOptions: [String]?, - inventoryFilePath: String?, - projectDirectory: String, - template: Configuration.Template, - useLocalTemplateDirectory: Bool - ) { - self.extraOptions = extraOptions - self.inventoryFilePath = inventoryFilePath - self.projectDirectory = projectDirectory - self.template = template - self.useLocalTemplateDirectory = useLocalTemplateDirectory - } - } - } - } - - struct RunCommandOptions: Sendable, Equatable { - public let arguments: [String] - public let quiet: Bool - public let shell: ShellCommand.Shell - - public init( - arguments: [String], - quiet: Bool, - shell: ShellCommand.Shell - ) { - self.arguments = arguments - self.quiet = quiet - self.shell = shell - } - } - - struct VaultOptions: Equatable, Sendable { - let arguments: [String] - let configuration: Configuration? - let quiet: Bool - let shell: String? - let vaultFilePath: String? - - public init( - arguments: [String], - configuration: Configuration? = nil, - quiet: Bool, - shell: String?, - vaultFilePath: String? = nil - ) { - self.arguments = arguments - self.configuration = configuration - self.quiet = quiet - self.shell = shell - self.vaultFilePath = vaultFilePath - } - } -} - -extension CliClient: DependencyKey { - - public static func live( - env: [String: String] - ) -> Self { - @Dependency(\.logger) var logger - - return .init { options in - @Dependency(\.asyncShellClient) var shellClient - if !options.quiet { - try await shellClient.foreground(.init( - shell: options.shell, - environment: ProcessInfo.processInfo.environment, - in: nil, - options.arguments - )) - } else { - try await shellClient.background(.init( - shell: options.shell, - environment: ProcessInfo.processInfo.environment, - in: nil, - options.arguments - )) - } - } generateJSON: { options, loggingOptions, encoder in - let data = try await createJSONData(options, logging: loggingOptions, encoder: encoder) - guard let string = String(data: data, encoding: .utf8) else { - throw CliClientError.encodingError - } - return string - } - } - - public static var liveValue: CliClient { - .live(env: ProcessInfo.processInfo.environment) - } - - public static let testValue: CliClient = Self() - - public static func capturing(_ client: CapturingClient) -> Self { - .init { options in - await client.set(options) - } generateJSON: { - try await Self().generateJSON($0, $1, $2) - } - } - - public actor CapturingClient: Sendable { - public private(set) var quiet: Bool? - public private(set) var shell: ShellCommand.Shell? - public private(set) var arguments: [String]? - - public init() {} - - public func set( - _ options: RunCommandOptions - ) { - quiet = options.quiet - shell = options.shell - arguments = options.arguments - } - } -} diff --git a/Sources/CliClient/LoggingExtensions.swift b/Sources/CliClient/LoggingExtensions.swift deleted file mode 100644 index 39a204c..0000000 --- a/Sources/CliClient/LoggingExtensions.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Dependencies -import Logging -import ShellClient - -// TODO: Remove. -public extension CliClient { - - @discardableResult - func withLogger( - _ options: LoggingOptions, - operation: @Sendable @escaping () async throws -> T - ) async rethrows -> T { - try await Self.withLogger(options, operation: operation) - } - - @discardableResult - static func withLogger( - _ options: LoggingOptions, - operation: @Sendable @escaping () async throws -> T - ) async rethrows -> T { - try await withDependencies { - $0.logger = .init(label: "\(Constants.executableName)") - $0.logger.logLevel = options.logLevel - $0.logger[metadataKey: "command"] = "\(options.commandName.blue)" - } operation: { - try await operation() - } - } -} diff --git a/Sources/ConfigurationClient/ConfigurationClient.swift b/Sources/ConfigurationClient/ConfigurationClient.swift index 9e35228..b767805 100644 --- a/Sources/ConfigurationClient/ConfigurationClient.swift +++ b/Sources/ConfigurationClient/ConfigurationClient.swift @@ -15,7 +15,7 @@ public extension DependencyValues { @DependencyClient public struct ConfigurationClient: Sendable { public var find: @Sendable () async throws -> File - var generate: @Sendable (File, Bool) async throws -> Void + public var generate: @Sendable (GenerateOptions) async throws -> String public var load: @Sendable (File?) async throws -> Configuration var write: @Sendable (File, Configuration, Bool) async throws -> Void @@ -24,13 +24,6 @@ public struct ConfigurationClient: Sendable { return try await load(file) } - public func generate( - at file: File, - force: Bool = false - ) async throws { - try await generate(file, force) - } - public func write( _ configuration: Configuration, to file: File, @@ -38,6 +31,28 @@ public struct ConfigurationClient: Sendable { ) async throws { try await write(file, configuration, force) } + + public struct GenerateOptions: Equatable, Sendable { + + public let force: Bool + public let json: Bool + public let path: Path? + + public init( + force: Bool = false, + json: Bool = false, + path: Path? = nil + ) { + self.force = force + self.json = json + self.path = path + } + + public enum Path: Equatable, Sendable { + case file(File) + case directory(String) + } + } } extension ConfigurationClient: DependencyKey { @@ -47,8 +62,8 @@ extension ConfigurationClient: DependencyKey { let liveClient = LiveConfigurationClient(environment: environment) return .init { try await liveClient.find() - } generate: { file, force in - try await liveClient.generate(at: file, force: force) + } generate: { + try await liveClient.generate($0) } load: { file in try await liveClient.load(file: file) } write: { file, configuration, force in @@ -126,10 +141,24 @@ struct LiveConfigurationClient { throw ConfigurationError.configurationNotFound } - func generate(at file: File, force: Bool) async throws { + func generate(_ options: ConfigurationClient.GenerateOptions) async throws -> String { @Dependency(\.logger) var logger - logger.debug("Begin generating configuration: \(file.path), force: \(force)") + let file: File + + if let path = options.path { + switch path { + case let .file(requestedFile): + file = requestedFile + case let .directory(directory): + file = .init("\(directory)/\(HPAKey.defaultFileNameWithoutExtension).\(options.json ? "json" : "toml")")! + } + } else { + let configDir = "\(environment.xdgConfigHome)/\(HPAKey.configDirName)" + file = .init("\(configDir)/\(HPAKey.defaultFileName)")! + } + + logger.debug("Begin generating configuration: \(file.path), force: \(options.force)") let expandedPath = file.path.replacingOccurrences( of: "~", @@ -138,7 +167,7 @@ struct LiveConfigurationClient { let fileUrl = URL(filePath: expandedPath) - if !force { + if !options.force { guard !fileManager.fileExists(fileUrl) else { throw ConfigurationError.fileExists(path: file.path) } @@ -166,8 +195,10 @@ struct LiveConfigurationClient { } else { // Json does not allow comments, so we write the mock configuration // to the file path. - try await write(.mock, to: File(fileUrl)!, force: force) + try await write(.mock, to: File(fileUrl)!, force: options.force) } + + return fileUrl.cleanFilePath } func load(file: File?) async throws -> Configuration { diff --git a/Sources/ConfigurationClient/Constants.swift b/Sources/ConfigurationClient/Constants.swift index 5deb41a..b4c40f2 100644 --- a/Sources/ConfigurationClient/Constants.swift +++ b/Sources/ConfigurationClient/Constants.swift @@ -11,6 +11,7 @@ public enum HPAKey { public static let resourceFileName = "hpa" public static let resourceFileExtension = "toml" public static let defaultFileName = "config.toml" + public static let defaultFileNameWithoutExtension = "config.toml" } extension [String: String] { diff --git a/Sources/PandocClient/Constants.swift b/Sources/PandocClient/Constants.swift new file mode 100644 index 0000000..a55c6bd --- /dev/null +++ b/Sources/PandocClient/Constants.swift @@ -0,0 +1,7 @@ +extension PandocClient { + enum Constants { + static let pandocCommand = "pandoc" + static let defaultOutputFileName = "Report" + static let defaultPdfEngine = "xelatex" + } +} diff --git a/Sources/PandocClient/PandocClient+Run.swift b/Sources/PandocClient/PandocClient+Run.swift new file mode 100644 index 0000000..987f93d --- /dev/null +++ b/Sources/PandocClient/PandocClient+Run.swift @@ -0,0 +1,241 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import Foundation +import PlaybookClient + +extension PandocClient.RunOptions { + + func run(_ fileType: PandocClient.FileType) async throws -> String { + @Dependency(\.commandClient) var commandClient + @Dependency(\.logger) var logger + @Dependency(\.playbookClient) var playbookClient + + return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) { + let ensuredOptions = try await self.ensuredOptions(fileType) + + let projectDirectory = self.projectDirectory ?? ProcessInfo.processInfo.environment["PWD"] + + guard let projectDirectory else { + throw ProjectDirectoryNotSpecified() + } + + if shouldBuild { + logger.debug("Building project...") + try await playbookClient.run.buildProject(.init( + projectDirectory: projectDirectory, + shared: .init( + extraOptions: nil, + inventoryFilePath: nil, + loggingOptions: loggingOptions, + quiet: quiet, + shell: shell + ) + )) + } + + let outputDirectory = self.outputDirectory ?? projectDirectory + let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)" + + let arguments = ensuredOptions.makeArguments( + outputPath: outputPath, + projectDirectory: projectDirectory + ) + + logger.debug("Pandoc arguments: \(arguments)") + + return (arguments, outputPath) + } + } + + func ensuredOptions( + _ fileType: PandocClient.FileType + ) async throws -> EnsuredPandocOptions { + @Dependency(\.configurationClient) var configurationClient + @Dependency(\.logger) var logger + + let configuration = try await configurationClient.findAndLoad() + logger.debug("Configuration: \(configuration)") + + return try await ensurePandocOptions( + configuration: configuration, + fileType: fileType, + options: self + ) + } +} + +@_spi(Internal) +public struct EnsuredPandocOptions: Equatable, Sendable { + public let buildDirectory: String + public let extraOptions: [String]? + public let files: [String] + public let includeInHeader: [String] + public let outputFileName: String + public let outputFileType: PandocClient.FileType + public let pdfEngine: String? + + public var ensuredExtensionFileName: String { + let extensionString: String + + switch outputFileType { + case .html: + extensionString = ".html" + case .latex: + extensionString = ".tex" + case .pdf: + extensionString = ".pdf" + } + + if !outputFileName.hasSuffix(extensionString) { + return outputFileName + extensionString + } + return outputFileName + } + + func makeArguments( + outputPath: String, + projectDirectory: String + ) -> [String] { + var arguments = [PandocClient.Constants.pandocCommand] + + arguments += includeInHeader.map { + "--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)" + } + + if let pdfEngine { + arguments.append("--pdf-engine=\(pdfEngine)") + } + + arguments.append("--output=\(outputPath)") + + if let extraOptions { + arguments.append(contentsOf: extraOptions) + } + + arguments += files.map { + "\(projectDirectory)/\(buildDirectory)/\($0)" + } + + return arguments + } +} + +@_spi(Internal) +public func ensurePandocOptions( + configuration: Configuration, + fileType: PandocClient.FileType, + options: PandocClient.RunOptions +) async throws -> EnsuredPandocOptions { + let defaults = Configuration.Generate.default + + return .init( + buildDirectory: options.parseBuildDirectory(configuration.generate, defaults), + extraOptions: options.extraOptions, + files: options.parseFiles(configuration.generate, defaults), + includeInHeader: options.parseIncludeInHeader(configuration.generate, defaults), + outputFileName: options.parseOutputFileName(configuration.generate, defaults), + outputFileType: fileType, + pdfEngine: fileType.parsePdfEngine(configuration.generate, defaults) + ) +} + +@_spi(Internal) +public extension PandocClient.FileType { + func parsePdfEngine( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> String? { + switch self { + case .html, .latex: + return nil + case let .pdf(engine: engine): + if let engine { + return engine + } else if let engine = configuration?.pdfEngine { + return engine + } else if let engine = defaults.pdfEngine { + return engine + } else { + return PandocClient.Constants.defaultPdfEngine + } + } + } +} + +@_spi(Internal) +public extension PandocClient.RunOptions { + func parseFiles( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> [String] { + @Dependency(\.logger) var logger + + if let files = files { + return files + } else if let files = configuration?.files { + return files + } else if let files = defaults.files { + return files + } else { + logger.warning("Files not specified, this could lead to errors.") + return [] + } + } + + func parseIncludeInHeader( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> [String] { + @Dependency(\.logger) var logger + + if let files = includeInHeader { + return files + } else if let files = configuration?.includeInHeader { + return files + } else if let files = defaults.includeInHeader { + return files + } else { + logger.warning("Include in header files not specified, this could lead to errors.") + return [] + } + } + + func parseOutputFileName( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> String { + @Dependency(\.logger) var logger + + if let output = outputFileName { + return output + } else if let output = configuration?.outputFileName { + return output + } else if let output = defaults.outputFileName { + return output + } else { + logger.warning("Output file name not specified, this could lead to errors.") + return PandocClient.Constants.defaultOutputFileName + } + } + + func parseBuildDirectory( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> String { + @Dependency(\.logger) var logger + + if let output = buildDirectory { + return output + } else if let output = configuration?.buildDirectory { + return output + } else if let output = defaults.buildDirectory { + return output + } else { + logger.warning("Output file name not specified, this could lead to errors.") + return ".build" + } + } +} + +struct ProjectDirectoryNotSpecified: Error {} diff --git a/Sources/PandocClient/PandocClient.swift b/Sources/PandocClient/PandocClient.swift new file mode 100644 index 0000000..54894ea --- /dev/null +++ b/Sources/PandocClient/PandocClient.swift @@ -0,0 +1,95 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import DependenciesMacros + +public extension DependencyValues { + var pandocClient: PandocClient { + get { self[PandocClient.self] } + set { self[PandocClient.self] = newValue } + } +} + +@DependencyClient +public struct PandocClient: Sendable { + + public var run: Run + + @DependencyClient + public struct Run: Sendable { + public var generateLatex: @Sendable (RunOptions) async throws -> String + public var generateHtml: @Sendable (RunOptions) async throws -> String + public var generatePdf: @Sendable (RunOptions, String?) async throws -> String + + public func generatePdf( + _ options: RunOptions, + pdfEngine: String? = nil + ) async throws -> String { + try await generatePdf(options, pdfEngine) + } + + } + + public struct RunOptions: Equatable, Sendable { + + let buildDirectory: String? + let extraOptions: [String]? + let files: [String]? + let loggingOptions: LoggingOptions + let includeInHeader: [String]? + let outputDirectory: String? + let outputFileName: String? + let projectDirectory: String? + let quiet: Bool + let shell: String? + let shouldBuild: Bool + + public init( + buildDirectory: String? = nil, + extraOptions: [String]? = nil, + files: [String]? = nil, + loggingOptions: LoggingOptions, + includeInHeader: [String]? = nil, + outputDirectory: String? = nil, + projectDirectory: String? = nil, + outputFileName: String? = nil, + quiet: Bool = false, + shell: String? = nil, + shouldBuild: Bool = true + ) { + self.buildDirectory = buildDirectory + self.extraOptions = extraOptions + self.files = files + self.loggingOptions = loggingOptions + self.includeInHeader = includeInHeader + self.outputDirectory = outputDirectory + self.outputFileName = outputFileName + self.projectDirectory = projectDirectory + self.quiet = quiet + self.shell = shell + self.shouldBuild = shouldBuild + } + + } + + @_spi(Internal) + public enum FileType: Equatable, Sendable { + case html + case latex + case pdf(engine: String?) + } +} + +extension PandocClient: DependencyKey { + public static let testValue: PandocClient = Self(run: Run()) + + public static var liveValue: PandocClient { + .init( + run: Run( + generateLatex: { try await $0.run(.latex) }, + generateHtml: { try await $0.run(.html) }, + generatePdf: { try await $0.run(.pdf(engine: $1)) } + ) + ) + } +} diff --git a/Sources/hpa/BuildCommand.swift b/Sources/hpa/BuildCommand.swift index 1280d45..362d7c8 100644 --- a/Sources/hpa/BuildCommand.swift +++ b/Sources/hpa/BuildCommand.swift @@ -1,5 +1,4 @@ import ArgumentParser -import CliClient import Dependencies import Foundation import PlaybookClient diff --git a/Sources/hpa/CreateCommand.swift b/Sources/hpa/CreateCommand.swift index 9e23c4e..d7931d8 100644 --- a/Sources/hpa/CreateCommand.swift +++ b/Sources/hpa/CreateCommand.swift @@ -1,5 +1,4 @@ import ArgumentParser -import CliClient import ConfigurationClient import Dependencies import Foundation @@ -71,14 +70,3 @@ struct CreateCommand: AsyncParsableCommand { print(projectDir) } } - -private extension CreateCommand { - var generateJsonOptions: CliClient.GenerateJsonOptions { - .init( - templateDirectory: templateDir, - templateRepo: repo, - version: branch, - useLocalTemplateDirectory: localTemplateDir - ) - } -} diff --git a/Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift b/Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift index 8533c36..3f6bf39 100644 --- a/Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift +++ b/Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift @@ -1,6 +1,6 @@ import ArgumentParser -import CliClient import Dependencies +import PandocClient // TODO: Need to add a step to build prior to generating file. struct GenerateHtmlCommand: AsyncParsableCommand { @@ -13,12 +13,12 @@ struct GenerateHtmlCommand: AsyncParsableCommand { @OptionGroup var globals: GenerateOptions mutating func run() async throws { - @Dependency(\.cliClient) var cliClient + @Dependency(\.pandocClient) var pandocClient - try await cliClient.runPandocCommand( - globals.pandocOptions(.html), - logging: globals.loggingOptions(commandName: Self.commandName) + let output = try await pandocClient.run.generateHtml( + globals.pandocRunOptions(commandName: Self.commandName) ) + print(output) } } diff --git a/Sources/hpa/GenerateCommands/GenerateLatexCommand.swift b/Sources/hpa/GenerateCommands/GenerateLatexCommand.swift index 2270286..5801bb0 100644 --- a/Sources/hpa/GenerateCommands/GenerateLatexCommand.swift +++ b/Sources/hpa/GenerateCommands/GenerateLatexCommand.swift @@ -1,5 +1,4 @@ import ArgumentParser -import CliClient import Dependencies // TODO: Need to add a step to build prior to generating file. @@ -13,11 +12,11 @@ struct GenerateLatexCommand: AsyncParsableCommand { @OptionGroup var globals: GenerateOptions mutating func run() async throws { - @Dependency(\.cliClient) var cliClient + @Dependency(\.pandocClient) var pandocClient - try await cliClient.runPandocCommand( - globals.pandocOptions(.latex), - logging: globals.loggingOptions(commandName: Self.commandName) + let output = try await pandocClient.run.generateLatex( + globals.pandocRunOptions(commandName: Self.commandName) ) + print(output) } } diff --git a/Sources/hpa/GenerateCommands/GenerateOptions.swift b/Sources/hpa/GenerateCommands/GenerateOptions.swift index d514ce4..93598b5 100644 --- a/Sources/hpa/GenerateCommands/GenerateOptions.swift +++ b/Sources/hpa/GenerateCommands/GenerateOptions.swift @@ -1,11 +1,19 @@ import ArgumentParser -import CliClient +import CommandClient +import PandocClient @dynamicMemberLookup struct GenerateOptions: ParsableArguments { @OptionGroup var basic: BasicGlobalOptions + @Option( + name: .shortAndLong, + help: "Custom build directory path.", + completion: .directory + ) + var buildDirectory: String? + @Option( name: [.short, .customLong("file")], help: "Files used to generate the output, can be specified multiple times.", @@ -65,20 +73,20 @@ struct GenerateOptions: ParsableArguments { extension GenerateOptions { - func loggingOptions(commandName: String) -> CliClient.LoggingOptions { + func loggingOptions(commandName: String) -> LoggingOptions { basic.loggingOptions(commandName: commandName) } - func pandocOptions( - _ fileType: CliClient.PandocOptions.FileType - ) -> CliClient.PandocOptions { + func pandocRunOptions(commandName: String) -> PandocClient.RunOptions { .init( + buildDirectory: buildDirectory, + extraOptions: extraOptions.count > 0 ? extraOptions : nil, files: files.count > 0 ? files : nil, + loggingOptions: .init(commandName: commandName, logLevel: .init(globals: basic, quietOnlyPlaybook: false)), includeInHeader: includeInHeader.count > 0 ? includeInHeader : nil, outputDirectory: outputDirectory, - outputFileName: outputFileName, - outputFileType: fileType, projectDirectory: projectDirectory, + outputFileName: outputFileName, quiet: basic.quiet, shell: basic.shell, shouldBuild: !noBuild diff --git a/Sources/hpa/GenerateCommands/GeneratePdfCommand.swift b/Sources/hpa/GenerateCommands/GeneratePdfCommand.swift index 4a86fb0..2dfab9a 100644 --- a/Sources/hpa/GenerateCommands/GeneratePdfCommand.swift +++ b/Sources/hpa/GenerateCommands/GeneratePdfCommand.swift @@ -1,6 +1,6 @@ import ArgumentParser -import CliClient import Dependencies +import PandocClient // TODO: Need to add a step to build prior to generating file. @@ -20,11 +20,11 @@ struct GeneratePdfCommand: AsyncParsableCommand { @OptionGroup var globals: GenerateOptions mutating func run() async throws { - @Dependency(\.cliClient) var cliClient + @Dependency(\.pandocClient) var pandocClient - let output = try await cliClient.runPandocCommand( - globals.pandocOptions(.pdf(engine: pdfEngine)), - logging: globals.loggingOptions(commandName: Self.commandName) + let output = try await pandocClient.run.generatePdf( + globals.pandocRunOptions(commandName: Self.commandName), + pdfEngine: pdfEngine ) print(output) diff --git a/Sources/hpa/Internal/Constants.swift b/Sources/hpa/Internal/Constants.swift index c35ea3f..c5d1433 100644 --- a/Sources/hpa/Internal/Constants.swift +++ b/Sources/hpa/Internal/Constants.swift @@ -3,6 +3,9 @@ import Rainbow // Constant string values. enum Constants { static let appName = "hpa" + static let brewPackages = [ + "ansible", "imagemagick", "pandoc", "texLive" + ] static let playbookFileName = "main.yml" static let inventoryFileName = "inventory.ini" static let importantExtraArgsNote = """ diff --git a/Sources/hpa/Internal/GlobalOptions.swift b/Sources/hpa/Internal/GlobalOptions.swift index f67a61e..f90710e 100644 --- a/Sources/hpa/Internal/GlobalOptions.swift +++ b/Sources/hpa/Internal/GlobalOptions.swift @@ -1,5 +1,5 @@ import ArgumentParser -import CliClient +import CommandClient import ConfigurationClient import PlaybookClient @@ -59,10 +59,8 @@ struct GlobalOptions: ParsableArguments { } -// TODO: Update these to use CommandClient.LoggingOptions - extension GlobalOptions { - func loggingOptions(commandName: String) -> CliClient.LoggingOptions { + func loggingOptions(commandName: String) -> LoggingOptions { .init( commandName: commandName, logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook) @@ -71,7 +69,7 @@ extension GlobalOptions { } extension BasicGlobalOptions { - func loggingOptions(commandName: String) -> CliClient.LoggingOptions { + func loggingOptions(commandName: String) -> LoggingOptions { .init( commandName: commandName, logLevel: .init(globals: self, quietOnlyPlaybook: false) @@ -79,24 +77,6 @@ extension BasicGlobalOptions { } } -// TODO: Remove -extension GlobalOptions { - - func playbookOptions( - arguments: [String], - configuration: Configuration? - ) -> CliClient.PlaybookOptions { - .init( - arguments: arguments, - configuration: configuration, - inventoryFilePath: inventoryPath, - playbookDirectory: playbookDirectory, - quiet: quietOnlyPlaybook ? true : basic.quiet, - shell: basic.shell - ) - } -} - extension GlobalOptions { func sharedPlaybookRunOptions( commandName: String, diff --git a/Sources/hpa/Internal/VaultOptions+Globals.swift b/Sources/hpa/Internal/VaultOptions+Globals.swift index 35d7298..c9fc390 100644 --- a/Sources/hpa/Internal/VaultOptions+Globals.swift +++ b/Sources/hpa/Internal/VaultOptions+Globals.swift @@ -1,18 +1,17 @@ -import CliClient -import ConfigurationClient - -extension VaultOptions { - - func vaultOptions( - arguments: [String], - configuration: Configuration? - ) -> CliClient.VaultOptions { - .init( - arguments: arguments, - configuration: configuration, - quiet: globals.quiet, - shell: globals.shell, - vaultFilePath: file - ) - } -} +// import ConfigurationClient +// +// extension VaultOptions { +// +// func vaultOptions( +// arguments: [String], +// configuration: Configuration? +// ) -> CliClient.VaultOptions { +// .init( +// arguments: arguments, +// configuration: configuration, +// quiet: globals.quiet, +// shell: globals.shell, +// vaultFilePath: file +// ) +// } +// } diff --git a/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift b/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift index efd36c6..096acd8 100644 --- a/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift +++ b/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift @@ -1,6 +1,6 @@ import ArgumentParser -import CliClient import CliDoc +import CommandClient import ConfigurationClient import Dependencies @@ -16,14 +16,14 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { Note { """ If a directory is not supplied then a configuration file will be created - at \("'~/.config/hpa-playbook/config'".yellow). + at \("'~/.config/hpa/config.toml'".yellow). """ } VStack { "EXAMPLE:".yellow.bold "Create a directory and generate the configuration".green - ShellCommand("mkdir -p ~/.config/hpa-playbook") - ShellCommand("hpa generate-config --path ~/.config/hpa-playbook") + ShellCommand("mkdir -p ~/.config/hpa") + ShellCommand("hpa generate-config --path ~/.config/hpa") } } .separator(.newLine(count: 2)) @@ -39,7 +39,7 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { @Flag( name: .shortAndLong, - help: "Generate a json file, instead of default env style" + help: "Generate a json file, instead of the default toml style" ) var json: Bool = false @@ -56,26 +56,18 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { } private func _run() async throws { - @Dependency(\.cliClient) var cliClient @Dependency(\.configurationClient) var configurationClient - try await cliClient.withLogger(globals.loggingOptions(commandName: Self.commandName)) { + try await globals.loggingOptions(commandName: Self.commandName).withLogger { @Dependency(\.logger) var logger - let actualPath: File + let output = try await configurationClient.generate(.init( + force: force, + json: json, + path: path != nil ? .directory(path!) : nil + )) - if let path, let file = File("\(path)/config.\(json ? "json" : "toml")") { - actualPath = file - } else { - actualPath = .default - } - - logger.debug("Generating config at path: \(actualPath.path)") - - try await configurationClient.generate( - at: actualPath, - force: force - ) + print(output) } } } diff --git a/Sources/hpa/UtilsCommands/GenerateProjectTemplateCommand.swift b/Sources/hpa/UtilsCommands/GenerateProjectTemplateCommand.swift index aaef502..2f12311 100644 --- a/Sources/hpa/UtilsCommands/GenerateProjectTemplateCommand.swift +++ b/Sources/hpa/UtilsCommands/GenerateProjectTemplateCommand.swift @@ -1,5 +1,4 @@ import ArgumentParser -import CliClient import Dependencies import PlaybookClient diff --git a/Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift b/Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift index ad2a4e6..13df49c 100644 --- a/Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift +++ b/Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift @@ -1,6 +1,6 @@ import ArgumentParser -import CliClient import CliDoc +import CommandClient import Dependencies struct InstallDependenciesCommand: AsyncParsableCommand { @@ -33,11 +33,20 @@ struct InstallDependenciesCommand: AsyncParsableCommand { var extraOptions: [String] = [] mutating func run() async throws { - @Dependency(\.cliClient) var cliClient - try await cliClient.installDependencies( + @Dependency(\.commandClient) var commandClient + @Dependency(\.playbookClient) var playbookClient + + let arguments = [ + "brew", "install" + ] + Constants.brewPackages + + extraOptions + + try await commandClient.run( quiet: globals.quiet, shell: globals.shell, - extraArgs: extraOptions + arguments ) + + try await playbookClient.repository.install() } } diff --git a/Sources/hpa/VaultCommands/DecryptCommand.swift b/Sources/hpa/VaultCommands/DecryptCommand.swift index 939f7e8..149ece5 100644 --- a/Sources/hpa/VaultCommands/DecryptCommand.swift +++ b/Sources/hpa/VaultCommands/DecryptCommand.swift @@ -1,5 +1,4 @@ import ArgumentParser -import CliClient import Dependencies import VaultClient diff --git a/Sources/hpa/VaultCommands/EncryptCommand.swift b/Sources/hpa/VaultCommands/EncryptCommand.swift index 8910862..952290e 100644 --- a/Sources/hpa/VaultCommands/EncryptCommand.swift +++ b/Sources/hpa/VaultCommands/EncryptCommand.swift @@ -1,5 +1,4 @@ import ArgumentParser -import CliClient import Dependencies import VaultClient diff --git a/Sources/hpa/VaultCommands/VaultOptions.swift b/Sources/hpa/VaultCommands/VaultOptions.swift index 58f28d8..dc3cf37 100644 --- a/Sources/hpa/VaultCommands/VaultOptions.swift +++ b/Sources/hpa/VaultCommands/VaultOptions.swift @@ -1,5 +1,5 @@ import ArgumentParser -import CliClient +import CommandClient import VaultClient // Holds the common options for vault commands, as they all share the @@ -32,12 +32,6 @@ struct VaultOptions: ParsableArguments { } -extension VaultOptions { - func loggingOptions(commandName: String) -> CliClient.LoggingOptions { - globals.loggingOptions(commandName: commandName) - } -} - extension VaultOptions { func runOptions( diff --git a/Tests/CliClientTests/CliClientTests.swift b/Tests/CliClientTests/CliClientTests.swift deleted file mode 100644 index 13dc6a7..0000000 --- a/Tests/CliClientTests/CliClientTests.swift +++ /dev/null @@ -1,333 +0,0 @@ -@_spi(Internal) import CliClient -import ConfigurationClient -import Dependencies -import FileClient -import Foundation -import PlaybookClient -import ShellClient -import Testing -import TestSupport - -@Suite("CliClientTests") -struct CliClientTests: TestCase { - - static let loggingOptions: CliClient.LoggingOptions = { - let levelString = ProcessInfo.processInfo.environment["LOGGING_LEVEL"] ?? "debug" - let logLevel = Logger.Level(rawValue: levelString) ?? .debug - return .init(commandName: "CliClientTests", logLevel: logLevel) - }() - - @Test - func capturingClient() async throws { - let captured = CliClient.CapturingClient() - let client = CliClient.capturing(captured) - try await client.runCommand(quiet: false, shell: .zsh(), "foo", "bar") - - let quiet = await captured.quiet! - #expect(quiet == false) - - let shell = await captured.shell - #expect(shell == .zsh()) - - let arguments = await captured.arguments! - #expect(arguments == ["foo", "bar"]) - } - - @Test(arguments: ["encrypt", "decrypt"]) - func runVault(argument: String) async throws { - let captured = CliClient.CapturingClient() - try await withMockConfiguration(captured, key: "runVault") { - $0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "vault.yml") } - } operation: { - @Dependency(\.cliClient) var cliClient - let configuration = Configuration.mock - - try await cliClient.runVaultCommand( - .init(arguments: [argument], quiet: false, shell: nil), - logging: Self.loggingOptions - ) - - let shell = await captured.shell - #expect(shell == .zsh(useDashC: true)) - - let vaultPath = URL(filePath: #file) - .deletingLastPathComponent() - .deletingLastPathComponent() - .appending(path: "vault.yml") - - var encryptArgs: [String] = [] - if argument == "encrypt", let id = configuration.vault.encryptId { - encryptArgs = ["--encrypt-vault-id", id] - } - - let expectedArguments = [ - "ansible-vault", argument - ] + configuration.vault.args! - + encryptArgs - + [vaultPath.cleanFilePath] - - let arguments = await captured.arguments - #expect(arguments == expectedArguments) - } - } - - @Test(arguments: [ - Configuration( - args: ["--tags", "debug"], - useVaultArgs: true, - playbook: .init(directory: "playbook", inventory: nil), - vault: .mock - ) - ]) - func runPlaybook(configuration: Configuration) async throws { - let captured = CliClient.CapturingClient() - try await withMockConfiguration(captured, configuration: configuration, key: "runPlaybook") { - @Dependency(\.cliClient) var cliClient - - try await cliClient.runPlaybookCommand( - .init( - arguments: [], - quiet: false, - shell: nil - ), - logging: Self.loggingOptions - ) - - let expectedArguments = [ - "ansible-playbook", "playbook/main.yml", - "--inventory", "playbook/inventory.ini", - "--tags", "debug", - "--vault-id=myId@$SCRIPTS/vault-gopass-client" - ] - - let arguments = await captured.arguments - #expect(arguments == expectedArguments) - } - } - -// @Test -// func ensuredPlaybookDirectory() throws { -// let configuration = Configuration.mock -// let playbookDir = try configuration.ensuredPlaybookDirectory("playbook") -// #expect(playbookDir == "playbook") -// -// do { -// _ = try configuration.ensuredPlaybookDirectory(nil) -// #expect(Bool(false)) -// } catch { -// #expect(Bool(true)) -// } -// } - - @Test - func shellOrDefault() { - var shell: String? = "/bin/bash" - #expect(shell.orDefault == .custom(path: "/bin/bash", useDashC: true)) - - shell = nil - #expect(shell.orDefault == .zsh(useDashC: true)) - } - - @Test - func testEnsuredInventoryPath() { - let configuration = Configuration(playbook: .init(inventory: "inventory.ini")) - let playbookDir = "playbook" - let inventoryPath = "inventory.ini" - - var output = ensuredInventoryPath( - inventoryPath, - configuration: configuration, - playbookDirectory: playbookDir - ) - - #expect(output == "inventory.ini") - - output = ensuredInventoryPath( - nil, - configuration: configuration, - playbookDirectory: playbookDir - ) - #expect(output == "inventory.ini") - } - - @Test - func vaultFilePath() async throws { - var fileClient = FileClient.testValue - fileClient.findVaultFileInCurrentDirectory = { URL(string: "vault.yml") } - - var output = try await fileClient.ensuredVaultFilePath("vault.yml") - #expect(output == "vault.yml") - - output = try await fileClient.ensuredVaultFilePath(nil) - - fileClient.findVaultFileInCurrentDirectory = { nil } - do { - _ = try await fileClient.ensuredVaultFilePath(nil) - #expect(Bool(false)) - } catch { - #expect(Bool(true)) - } - } - - @Test( - arguments: [ - GenerateJsonTestOption( - options: .init( - templateDirectory: nil, - templateRepo: nil, - version: nil, - useLocalTemplateDirectory: true - ), - configuration: nil, - expectation: .failing - ), - GenerateJsonTestOption( - options: .init( - templateDirectory: nil, - templateRepo: nil, - version: nil, - useLocalTemplateDirectory: false - ), - configuration: nil, - expectation: .failing - ), - GenerateJsonTestOption( - options: .init( - templateDirectory: "template", - templateRepo: nil, - version: nil, - useLocalTemplateDirectory: true - ), - configuration: nil, - expectation: .success(""" - { - "template" : { - "path" : "template" - } - } - """) - ), - GenerateJsonTestOption( - options: .init( - templateDirectory: nil, - templateRepo: nil, - version: nil, - useLocalTemplateDirectory: false - ), - configuration: .init(template: .init(directory: "template")), - expectation: .success(""" - { - "template" : { - "path" : "template" - } - } - """) - ), - GenerateJsonTestOption( - options: .init( - templateDirectory: nil, - templateRepo: "https://git.example.com/template.git", - version: nil, - useLocalTemplateDirectory: false - ), - configuration: nil, - expectation: .success(""" - { - "template" : { - "repo" : { - "url" : "https://git.example.com/template.git", - "version" : "main" - } - } - } - """) - ), - GenerateJsonTestOption( - options: .init( - templateDirectory: nil, - templateRepo: nil, - version: nil, - useLocalTemplateDirectory: false - ), - configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")), - expectation: .success(""" - { - "template" : { - "repo" : { - "url" : "https://git.example.com/template.git", - "version" : "v0.1.0" - } - } - } - """) - ) - ] - ) - func generateJson(input: GenerateJsonTestOption) async { - await withTestLogger(key: "generateJson") { - $0.configurationClient = .mock(input.configuration ?? .init()) - $0.cliClient = .liveValue - } operation: { - @Dependency(\.cliClient) var cliClient - - let json = try? await cliClient.generateJSON( - input.options, - logging: Self.loggingOptions, - encoder: jsonEncoder - ) - - switch input.expectation { - case let .success(expected): - #expect(json == expected) - case .failing: - #expect(json == nil) - } - } - } - - func withMockConfiguration( - _ capturing: CliClient.CapturingClient, - configuration: Configuration = .mock, - key: String, - logLevel: Logger.Level = .trace, - depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, - operation: @Sendable @escaping () async throws -> Void - ) async rethrows { - try await withTestLogger(key: key, logLevel: logLevel) { - $0.configurationClient = .mock(configuration) - $0.cliClient = .capturing(capturing) - $0.playbookClient = .liveValue - setupDependencies(&$0) - } operation: { - try await operation() - } - } -} - -struct GenerateJsonTestOption: Sendable { - let options: CliClient.GenerateJsonOptions - let configuration: Configuration? - let expectation: GenerateJsonExpectation -} - -enum GenerateJsonExpectation: Sendable { - case failing - case success(String) -} - -extension ConfigurationClient { - static func mock(_ configuration: Configuration) -> Self { - var mock = Self.testValue - mock.find = { throw TestError() } - mock.load = { _ in configuration } - return mock - } -} - -struct TestError: Error {} - -let jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] - return encoder -}() diff --git a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift index 968a20f..8fef56b 100644 --- a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift +++ b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift @@ -26,13 +26,22 @@ struct ConfigurationClientTests: TestCase { try await withTemporaryDirectory { tempDir in let tempFile = tempDir.appending(path: fileName) - try await configuration.generate(at: File(tempFile)!, force: false) + let output = try await configuration.generate(.init( + force: false, + json: fileName.hasSuffix("json"), + path: .file(File(tempFile)!) + )) #expect(FileManager.default.fileExists(atPath: tempFile.cleanFilePath)) #expect(fileClient.fileExists(tempFile)) + #expect(output == tempFile.cleanFilePath) // Ensure that we do not overwrite files if they exist. do { - try await configuration.generate(at: File(tempFile)!, force: false) + _ = try await configuration.generate(.init( + force: false, + json: fileName.hasSuffix("json"), + path: .file(File(tempFile)!) + )) #expect(Bool(false)) } catch { #expect(Bool(true)) @@ -150,7 +159,11 @@ func withGeneratedConfigFile( ) async rethrows { try await withTemporaryDirectory { tempDir in let file = File(tempDir.appending(path: fileName))! - try await client.generate(at: file) + _ = try await client.generate(.init( + force: true, + json: fileName.hasSuffix("json"), + path: .file(file) + )) try await operation(file) } } @@ -167,7 +180,11 @@ func withGeneratedXDGConfigFile( withIntermediateDirectories: false ) let file = File(xdgDir.appending(path: fileName))! - try await client.generate(at: file) + _ = try await client.generate(.init( + force: true, + json: fileName.hasSuffix("json"), + path: .file(file) + )) try await operation(file, tempDir) } } diff --git a/Tests/PandocClientTests/PandocClientTests.swift b/Tests/PandocClientTests/PandocClientTests.swift new file mode 100644 index 0000000..9537c28 --- /dev/null +++ b/Tests/PandocClientTests/PandocClientTests.swift @@ -0,0 +1,333 @@ +@_spi(Internal) import ConfigurationClient +@_spi(Internal) import PandocClient +import PlaybookClient +import Testing +import TestSupport + +@Suite("PandocClientTests") +struct PandocClientTests: TestCase { + + static let outputDirectory = "/output" + static let projectDirectory = "/project" + static let defaultFileName = "Report" + + static let expectedIncludeInHeaders = [ + "--include-in-header=/project/.build/head.tex", + "--include-in-header=/project/.build/footer.tex" + ] + + static let expectedFiles = [ + "/project/.build/Report.md", + "/project/.build/Definitions.md" + ] + + static var sharedRunOptions: PandocClient.RunOptions { + .init( + buildDirectory: nil, + files: nil, + loggingOptions: loggingOptions, + includeInHeader: nil, + outputDirectory: outputDirectory, + projectDirectory: projectDirectory, + outputFileName: nil, + quiet: false, + shell: nil, + shouldBuild: true + ) + } + + @Test + func generateLatex() async throws { + try await withCapturingCommandClient("generateLatex") { + $0.configurationClient = .mock() + $0.playbookClient.run.buildProject = { _ in } + $0.pandocClient = .liveValue + } run: { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generateLatex(Self.sharedRunOptions) + #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).tex") + + } assert: { output in + let expected = ["pandoc"] + + Self.expectedIncludeInHeaders + + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).tex"] + + Self.expectedFiles + + #expect(output.arguments == expected) + } + } + + @Test + func generateHtml() async throws { + try await withCapturingCommandClient("generateHtml") { + $0.configurationClient = .mock() + $0.playbookClient.run.buildProject = { _ in } + $0.pandocClient = .liveValue + } run: { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generateHtml(Self.sharedRunOptions) + #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).html") + + } assert: { output in + let expected = ["pandoc"] + + Self.expectedIncludeInHeaders + + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).html"] + + Self.expectedFiles + + #expect(output.arguments == expected) + } + } + + @Test( + arguments: [ + nil, + "lualatex" + ] + ) + func generatePdf(pdfEngine: String?) async throws { + try await withCapturingCommandClient("generatePdf") { + $0.configurationClient = .mock() + $0.playbookClient.run.buildProject = { _ in } + $0.pandocClient = .liveValue + } run: { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generatePdf(Self.sharedRunOptions, pdfEngine: pdfEngine) + #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).pdf") + + } assert: { output in + let expected = ["pandoc"] + + Self.expectedIncludeInHeaders + + ["--pdf-engine=\(pdfEngine ?? "xelatex")"] + + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).pdf"] + + Self.expectedFiles + + #expect(output.arguments == expected) + } + } + + @Test(arguments: TestPdfEngine.testCases) + func parsePdfEngine(input: TestPdfEngine) { + #expect(input.engine == input.expectedEngine) + } + + @Test(arguments: TestParseFiles.testCases) + func parseFiles(input: TestParseFiles) { + #expect(input.parsedFiles == input.expectedFiles) + } + + @Test(arguments: TestParseIncludeInHeaderFiles.testCases) + func parseInclueInHeaderFiles(input: TestParseIncludeInHeaderFiles) { + #expect(input.parsedFiles == input.expectedHeaderFiles) + } + + @Test(arguments: TestParseOutputFileName.testCases) + func parseOutputFileName(input: TestParseOutputFileName) { + #expect(input.parsedFileName == input.expected) + } + + @Test(arguments: TestParseBuildDirectory.testCases) + func parseBuildDirectory(input: TestParseBuildDirectory) { + #expect(input.parsedBuildDirectory == input.expected) + } +} + +struct TestPdfEngine: Sendable { + let fileType: PandocClient.FileType + let expectedEngine: String? + let configuration: Configuration + let defaults: Configuration.Generate + + var engine: String? { + fileType.parsePdfEngine(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(fileType: .html, expectedEngine: nil, configuration: .init(), defaults: .default), + .init(fileType: .latex, expectedEngine: nil, configuration: .init(), defaults: .default), + .init(fileType: .pdf(engine: "lualatex"), expectedEngine: "lualatex", configuration: .init(), defaults: .default), + .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .default), + .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .init()), + .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(generate: .default), defaults: .init()) + ] +} + +struct TestParseFiles: Sendable { + + let expectedFiles: [String] + let configuration: Configuration + let defaults: Configuration.Generate + let runOptions: PandocClient.RunOptions? + + init( + expectedFiles: [String], + configuration: Configuration = .init(), + defaults: Configuration.Generate = .default, + runOptions: PandocClient.RunOptions? = nil + ) { + self.expectedFiles = expectedFiles + self.configuration = configuration + self.defaults = defaults + self.runOptions = runOptions + } + + var parsedFiles: [String] { + let runOptions = self.runOptions ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + projectDirectory: nil, + quiet: true, + shouldBuild: false + ) + + return runOptions.parseFiles(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(expectedFiles: ["Report.md", "Definitions.md"]), + .init(expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default), defaults: .init()), + .init(expectedFiles: [], defaults: .init()), + .init( + expectedFiles: ["custom.md"], + configuration: .init(), + defaults: .init(), + runOptions: .init( + files: ["custom.md"], + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + projectDirectory: nil, + quiet: true, + shouldBuild: false + ) + ) + ] +} + +struct TestParseIncludeInHeaderFiles: Sendable { + + let expectedHeaderFiles: [String] + let configuration: Configuration + let defaults: Configuration.Generate + let runOptions: PandocClient.RunOptions? + + init( + expectedHeaderFiles: [String], + configuration: Configuration = .init(), + defaults: Configuration.Generate = .default, + runOptions: PandocClient.RunOptions? = nil + ) { + self.expectedHeaderFiles = expectedHeaderFiles + self.configuration = configuration + self.defaults = defaults + self.runOptions = runOptions + } + + var parsedFiles: [String] { + let runOptions = self.runOptions ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) + + return runOptions.parseIncludeInHeader(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(expectedHeaderFiles: ["head.tex", "footer.tex"]), + .init(expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default), defaults: .init()), + .init(expectedHeaderFiles: [], defaults: .init()), + .init( + expectedHeaderFiles: ["custom.tex"], + configuration: .init(), + defaults: .init(), + runOptions: .init( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + includeInHeader: ["custom.tex"] + ) + ) + ] +} + +struct TestParseOutputFileName: Sendable { + + let expected: String + let configuration: Configuration + let defaults: Configuration.Generate + let runOptions: PandocClient.RunOptions? + + init( + expected: String, + configuration: Configuration = .init(), + defaults: Configuration.Generate = .default, + runOptions: PandocClient.RunOptions? = nil + ) { + self.expected = expected + self.configuration = configuration + self.defaults = defaults + self.runOptions = runOptions + } + + var parsedFileName: String { + let runOptions = self.runOptions ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) + + return runOptions.parseOutputFileName(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(expected: "Report"), + .init(expected: "Report", configuration: .init(generate: .default), defaults: .init()), + .init(expected: "Report", defaults: .init()), + .init( + expected: "custom", + configuration: .init(), + defaults: .init(), + runOptions: .init( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + outputFileName: "custom" + ) + ) + ] +} + +struct TestParseBuildDirectory: Sendable { + + let expected: String + let configuration: Configuration + let defaults: Configuration.Generate + let runOptions: PandocClient.RunOptions? + + init( + expected: String = ".build", + configuration: Configuration = .init(), + defaults: Configuration.Generate = .default, + runOptions: PandocClient.RunOptions? = nil + ) { + self.expected = expected + self.configuration = configuration + self.defaults = defaults + self.runOptions = runOptions + } + + var parsedBuildDirectory: String { + let runOptions = self.runOptions ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) + + return runOptions.parseBuildDirectory(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(), + .init(configuration: .init(generate: .default), defaults: .init()), + .init(defaults: .init()), + .init( + expected: "custom", + configuration: .init(), + defaults: .init(), + runOptions: .init( + buildDirectory: "custom", + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) + ) + ] +}