9 Commits

Author SHA1 Message Date
558054464c feat: Create backups of configuration when the force option is not used.
Some checks failed
CI / Run Tests (push) Failing after 2m16s
Create and publish a Docker image / build-and-push-image (push) Has been cancelled
2024-12-17 14:38:52 -05:00
54c07886ad feat: Adding documentation comments.
All checks were successful
CI / Run Tests (push) Successful in 2m28s
2024-12-17 13:32:05 -05:00
f8e89ed0fa feat: Adding documentation comments.
All checks were successful
CI / Run Tests (push) Successful in 2m17s
2024-12-17 11:51:03 -05:00
f596975bbc feat: Fix test-docker command to not use TTY for CI tests.
All checks were successful
CI / Run Tests (push) Successful in 2m28s
2024-12-17 10:25:24 -05:00
805100fa43 feat: Adds ci
Some checks failed
CI / Run Tests (push) Failing after 3m47s
2024-12-17 10:17:46 -05:00
f7f168b7fd feat: Begins adding docker containers 2024-12-17 10:15:07 -05:00
99459a0a71 feat: Adds dump-configuration command. 2024-12-16 20:44:30 -05:00
f89efc8c5e feat: Adds file-client tests. 2024-12-16 19:00:42 -05:00
85b285347b feat: Removes cli-client 2024-12-16 17:14:25 -05:00
53 changed files with 1589 additions and 1687 deletions

20
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,20 @@
---
name: CI
on:
push:
branches: ["main", "dev"]
pull_request:
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: https://git.housh.dev/actions/setup-just@v1
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Run Test
run: just test-docker

68
.gitea/workflows/release.yml Executable file
View File

@@ -0,0 +1,68 @@
#
name: Create and publish a Docker image
# Configures this workflow to run every time a change is pushed to the branch called `release`.
on:
push:
branches: ['release']
tags:
- '*'
workflow_dispatch:
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
env:
REGISTRY: git.housh.dev
IMAGE_NAME: ${{ gitea.repository }}
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
packages: write
attestations: write
id-token: write
#
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.CONTAINER_TOKEN }}
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
# - name: Generate artifact attestation
# uses: actions/attest-build-provenance@v1
# with:
# subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
# subject-digest: ${{ steps.push.outputs.digest }}
# push-to-registry: true
# github-token: ${{ secrets.CONTAINER_TOKEN }}

View File

@@ -1,5 +1,5 @@
{
"originHash" : "a3b90cdeec7e11e73a0a47c1f8dffa472c22f0c6385282eb11096210e3e7ad8b",
"originHash" : "bc31b11e5e7d488e0a9c1bf91cb572d29f782bfd8e43f44157036f8f3d282893",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -64,6 +64,15 @@
"version" : "1.3.0"
}
},
{
"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",

View File

@@ -7,16 +7,17 @@ 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: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.2"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.0"),
.package(url: "https://git.housh.dev/michael/swift-cli-doc.git", from: "0.2.0"),
@@ -27,36 +28,18 @@ let package = Package(
.executableTarget(
name: "hpa",
dependencies: [
"CliClient",
"ConfigurationClient",
"FileClient",
"PandocClient",
"PlaybookClient",
"VaultClient",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "CliDoc", package: "swift-cli-doc"),
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.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 +93,24 @@ let package = Package(
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.testTarget(
name: "FileClientTests",
dependencies: ["FileClient", "TestSupport"]
),
.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: [

View File

@@ -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 {}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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"
]
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -1,29 +0,0 @@
import Dependencies
import Logging
import ShellClient
// TODO: Remove.
public extension CliClient {
@discardableResult
func withLogger<T>(
_ options: LoggingOptions,
operation: @Sendable @escaping () async throws -> T
) async rethrows -> T {
try await Self.withLogger(options, operation: operation)
}
@discardableResult
static func withLogger<T>(
_ 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()
}
}
}

View File

@@ -4,6 +4,8 @@ import Foundation
import TOMLKit
public extension DependencyValues {
/// Holds onto decoders and encoders for json and toml files.
var coders: Coders {
get { self[Coders.self] }
set { self[Coders.self] = newValue }

View File

@@ -110,15 +110,33 @@ public struct CommandClient: Sendable {
}
}
/// Represents logger setup options used when running commands.
///
/// This set's up metadata tags and keys appropriately for the command being run,
/// which can aid in debugging.
///
public struct LoggingOptions: Equatable, Sendable {
/// The command name / key that the logger is running for.
public let commandName: String
/// The log level.
public let logLevel: Logger.Level
/// Create the logging options.
///
/// - Parameters:
/// - commandName: The command name / key that the logger is running for.
/// - logLevel: The log level.
public init(commandName: String, logLevel: Logger.Level) {
self.commandName = commandName
self.logLevel = logLevel
}
/// Perform an operation with a setup logger.
///
/// - Parameters:
/// - operation: The operation to perform with the setup logger.
@discardableResult
public func withLogger<T>(
operation: @Sendable @escaping () async throws -> T
@@ -204,7 +222,12 @@ extension ShellCommand.Shell {
if let path {
self = .custom(path: path, useDashC: true)
} else {
self = .zsh(useDashC: true)
#if os(macOS)
self = .zsh(useDashC: true)
#else
// Generally we're in a docker container when this occurs, which does not have `zsh` installed.
self = .sh(useDashC: true)
#endif
}
}
}

View File

@@ -2,13 +2,28 @@ import Foundation
// NOTE: When adding items, then the 'hpa.toml' resource file needs to be updated.
/// Represents configurable settings for the command line tool.
/// Represents configurable settings for the application.
public struct Configuration: Codable, Equatable, Sendable {
/// Default arguments / options that can get passed into
/// ansible-playbook commands.
public let args: [String]?
/// Whether to use the vault arguments as defaults that get passed into
/// the ansible-playbook commands.
public let useVaultArgs: Bool
/// Configuration for when generating files from templated directories.
public let generate: Generate?
/// Configuration of the ansible-playbook, these are more for developing the
/// playbook locally and generally not needed by the user.
public let playbook: Playbook?
/// Template configuration options.
public let template: Template
/// Ansible-vault configuration options.
public let vault: Vault
public init(
@@ -37,11 +52,34 @@ public struct Configuration: Codable, Equatable, Sendable {
)
}
/// Configuration options for running `pandoc` commands that generate files from
/// a templated directory.
///
/// ## NOTE: Most of these can also be set by the template repository variables.
///
///
public struct Generate: Codable, Equatable, Sendable {
/// Specifiy the name of the build directory, generally `.build`.
public let buildDirectory: String?
/// Specifiy the files used in the `pandoc` command to generate a final output file.
///
/// - SeeAlso: `pandoc --help`
public let files: [String]?
/// Specifiy the files used in the `pandoc` command to include in the header.
///
/// - SeeAlso: `pandoc --help`
public let includeInHeader: [String]?
/// The default name of the output file, this does not require the file extension as we will
/// add it based on the command that is called. Generally this is 'Report'.
///
public let outputFileName: String?
/// The default pdf engine to use when generating pdf files using `pandoc`. Generally
/// this is 'xelatex'.
public let pdfEngine: String?
public init(
@@ -58,8 +96,10 @@ public struct Configuration: Codable, Equatable, Sendable {
self.pdfEngine = pdfEngine
}
/// Represents the default configuration for generating files using `pandoc`.
public static let `default` = Self.mock
/// Represents mock configuration for generating files using `pandoc`.
public static var mock: Self {
.init(
buildDirectory: ".build",
@@ -71,10 +111,22 @@ public struct Configuration: Codable, Equatable, Sendable {
}
}
/// Configuration options for the ansible-hpa-playbook. The playbook is
/// the primary driver of templating files, generating repository templates, and building
/// projects.
///
/// ## NOTE: These are generally only used for local development of the playbook.
///
///
public struct Playbook: Codable, Equatable, Sendable {
/// The directory location of the ansible playbook.
public let directory: String?
/// The inventory file name / location.
public let inventory: String?
/// The playbook version, branch, or tag.
public let version: String?
public init(
@@ -90,9 +142,28 @@ public struct Configuration: Codable, Equatable, Sendable {
public static var mock: Self { .init() }
}
/// Configuration settings for the user's template repository or directory.
///
/// A template is what is used to create projects for a user. Generally they setup
/// their own template by customizing the default template to their needs. This template
/// can then be stored as a git repository or on the local file system.
///
/// The template will hold variables and files that are used to generate the final output
/// files using `pandoc`. Generally the template will hold files that may only need setup once,
/// such as header files, footer files, and definitions.
///
/// The project directory contains dynamic variables / files that need edited in order
/// to generate the final output. This allows the project directory to remain smaller so the user
/// can more easily focus on the tasks required to generate the final output files.
public struct Template: Codable, Equatable, Sendable {
/// A url of the template when it is a remote git repository.
public let url: String?
/// The version, branch, or tag of the remote repository.
public let version: String?
/// The local directory that contains the template on the local file system.
public let directory: String?
public init(
@@ -114,8 +185,23 @@ public struct Configuration: Codable, Equatable, Sendable {
}
}
/// Configuration for `ansible-vault` commands. Ansible vault is used to encrypt and
/// decrypt sensitive data. This allows for variables, such as customer name and address
/// to be stored along with the project in an encrypted file so that it is safer to store them.
///
/// These may also be used in general `ansible-playbook` commands if the `useVaultArgs` is set
/// to `true` in a users configuration.
///
public struct Vault: Codable, Equatable, Sendable {
/// A list of arguments / options that get passed to the `ansible-vault` command.
///
/// - SeeAlso: `ansible-vault --help`
public let args: [String]?
/// An id that is used during encrypting `ansible-vault` files.
///
/// - SeeAlso: `ansible-vault encrypt --help`
public let encryptId: String?
public init(

View File

@@ -6,37 +6,119 @@ import Foundation
import ShellClient
public extension DependencyValues {
/// Interacts with the user's configuration.
var configurationClient: ConfigurationClient {
get { self[ConfigurationClient.self] }
set { self[ConfigurationClient.self] = newValue }
}
}
/// Represents actions that can be taken on user's configuration files.
///
///
@DependencyClient
public struct ConfigurationClient: Sendable {
public var find: @Sendable () async throws -> File
var generate: @Sendable (File, Bool) async throws -> Void
public var load: @Sendable (File?) async throws -> Configuration
var write: @Sendable (File, Configuration, Bool) async throws -> Void
/// Find the user's configuration, searches in the current directory and default
/// locations where configuration can be stored. An error is thrown if no configuration
/// is found.
public var find: @Sendable () async throws -> File
/// Generate a configuration file for the user.
public var generate: @Sendable (GenerateOptions) async throws -> String
/// Load a configuration file from the given file location. If the file is
/// not provided then we return an empty configuraion item.
public var load: @Sendable (File?) async throws -> Configuration
/// Write the configuration to the given file, optionally forcing an overwrite of
/// the file.
///
/// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control
/// what happens during tests, then you can customize the behavior of the fileClient.
///
public var write: @Sendable (WriteOptions) async throws -> Void
/// Find the user's configuration and load it.
public func findAndLoad() async throws -> Configuration {
let file = try? await find()
return try await load(file)
}
public func generate(
at file: File,
force: Bool = false
) async throws {
try await generate(file, force)
}
/// Write the configuration to the given file, optionally forcing an overwrite of
/// the file. If a file already exists and force is not supplied then we will create
/// a backup of the existing file.
///
/// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control
/// what happens during tests, then you can customize the behavior of the fileClient.
///
/// - Parameters:
/// - configuration: The configuration to save.
/// - file: The file location and type to save.
/// - force: Force overwritting if a file already exists.
public func write(
_ configuration: Configuration,
to file: File,
force: Bool = false
) async throws {
try await write(file, configuration, force)
try await write(.init(configuration, to: file, force: force))
}
/// Represents the options to generate a configuration file for a user.
public struct GenerateOptions: Equatable, Sendable {
/// Force generation, overwritting existing file if it exists.
public let force: Bool
/// Generate a `json` file instead of the default `toml` file.
public let json: Bool
/// The path to generate a file in.
public let path: Path?
public init(
force: Bool = false,
json: Bool = false,
path: Path? = nil
) {
self.force = force
self.json = json
self.path = path
}
/// Represents the path option for generating a configuration file for a user.
///
/// This can either be a full file path or a directory. If a directory is supplied
/// then we will use the default file name of 'config' and add the extension dependning
/// on if the caller wants a `json` or `toml` file.
public enum Path: Equatable, Sendable {
case file(File)
case directory(String)
}
}
/// Represents the options required to write a configuration file.
public struct WriteOptions: Equatable, Sendable {
/// The configuration to wrtie to the file path.
public let configuration: Configuration
/// The file path to write the configuration to.
public let file: File
/// Force overwritting an existing file, if it exists.
public let force: Bool
public init(
_ configuration: Configuration,
to file: File,
force: Bool
) {
self.configuration = configuration
self.file = file
self.force = force
}
}
}
@@ -45,15 +127,12 @@ extension ConfigurationClient: DependencyKey {
public static func live(environment: [String: String]) -> Self {
let liveClient = LiveConfigurationClient(environment: environment)
return .init {
try await liveClient.find()
} generate: { file, force in
try await liveClient.generate(at: file, force: force)
} load: { file in
try await liveClient.load(file: file)
} write: { file, configuration, force in
try await liveClient.write(configuration, to: file, force: force)
}
return .init(
find: { try await liveClient.find() },
generate: { try await liveClient.generate($0) },
load: { try await liveClient.load(file: $0) },
write: { try await liveClient.write($0) }
)
}
public static var liveValue: Self {
@@ -126,10 +205,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,10 +231,10 @@ struct LiveConfigurationClient {
let fileUrl = URL(filePath: expandedPath)
if !force {
guard !fileManager.fileExists(fileUrl) else {
throw ConfigurationError.fileExists(path: file.path)
}
let exists = fileManager.fileExists(fileUrl)
if !options.force, exists {
try await createBackup(file.url)
}
let fileDirectory = fileUrl.deletingLastPathComponent()
@@ -152,6 +245,8 @@ struct LiveConfigurationClient {
try await fileManager.createDirectory(fileDirectory)
}
// TODO: The hpa file needs to be copied somewhere on the system during install and
// not use bundle, as it only works if the tool was built on the users system.
if case .toml = file {
// In the case of toml, we copy the internal resource that includes
// usage comments in the file.
@@ -166,8 +261,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(.init(.mock, to: File(fileUrl)!, force: options.force))
}
return fileUrl.cleanFilePath
}
func load(file: File?) async throws -> Configuration {
@@ -186,14 +283,16 @@ struct LiveConfigurationClient {
}
func write(
_ configuration: Configuration,
to file: File,
force: Bool
_ options: ConfigurationClient.WriteOptions
) async throws {
if !force {
guard !fileManager.fileExists(file.url) else {
throw ConfigurationError.fileExists(path: file.path)
}
let configuration = options.configuration
let file = options.file
let force = options.force
let exists = fileManager.fileExists(file.url)
if !force, exists {
try await createBackup(file.url)
}
let data: Data
@@ -209,6 +308,13 @@ struct LiveConfigurationClient {
try await fileManager.write(data, file.url)
}
private func createBackup(_ url: URL) async throws {
let backupUrl = url.appendingPathExtension("back")
logger.warning("File exists, creating a backup of the existing file at: \(backupUrl.cleanFilePath)")
try await fileManager.copy(url, backupUrl)
try await fileManager.delete(url)
}
private func findInDirectory(_ directory: URL) async -> File? {
for file in validFileNames {
let url = directory.appending(path: file)

View File

@@ -1,3 +1,5 @@
/// Represents keys in the environment that can be used to locate a user's
/// configuration file.
@_spi(Internal)
public enum EnvironmentKey {
static let xdgConfigHome = "XDG_CONFIG_HOME"
@@ -5,12 +7,14 @@ public enum EnvironmentKey {
static let hpaConfigFile = "HPA_CONFIG_FILE"
}
/// Represents keys that are used internally for directory names, file names, etc.
@_spi(Internal)
public enum HPAKey {
public static let configDirName = "hpa"
public static let resourceFileName = "hpa"
public static let resourceFileExtension = "toml"
public static let defaultFileName = "config.toml"
public static let defaultFileNameWithoutExtension = "config"
}
extension [String: String] {

View File

@@ -2,11 +2,21 @@ import FileClient
import Foundation
/// Represents a file location and type on disk for a configuration file.
///
/// Currently the supported formats are `json` or `toml`. Toml is the default /
/// preferred format because it allows comments in the file and is easy to understand.
///
public enum File: Equatable, Sendable {
case json(URL)
case toml(URL)
/// Attempts to create a file with the given url.
///
/// ## NOTE: There are no checks on if a file / path actually exists or not.
///
/// If the file does not have a suffix of `json` or `toml` then
/// we will return `nil`.
public init?(_ url: URL) {
if url.cleanFilePath.hasSuffix("json") {
self = .json(url)
@@ -17,18 +27,17 @@ public enum File: Equatable, Sendable {
}
}
/// Attempts to create a file with the given path.
///
/// ## NOTE: There are no checks on if a file / path actually exists or not.
///
/// If the file does not have a suffix of `json` or `toml` then
/// we will return `nil`.
public init?(_ path: String) {
self.init(URL(filePath: path))
}
public static func json(_ path: String) -> Self {
.json(URL(filePath: path))
}
public static func toml(_ path: String) -> Self {
.toml(URL(filePath: path))
}
/// Get the url of the file.
public var url: URL {
switch self {
case let .json(url): return url
@@ -36,10 +45,14 @@ public enum File: Equatable, Sendable {
}
}
/// Get the path string of the file.
public var path: String {
url.cleanFilePath
}
/// Represents the default file path for a user's configuration.
///
/// Which is `~/.config/hpa/config.toml`
public static var `default`: Self {
let fileUrl = FileManager.default
.homeDirectoryForCurrentUser

View File

@@ -3,22 +3,53 @@ import DependenciesMacros
import Foundation
public extension DependencyValues {
/// Represents interactions with the file system.
///
var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
/// Represents interactions with the file system.
///
///
@DependencyClient
public struct FileClient: Sendable {
/// Copy an item from one location to another.
public var copy: @Sendable (URL, URL) async throws -> Void
/// Create a directory at the given location.
public var createDirectory: @Sendable (URL) async throws -> Void
/// Delete the item at the given location.
public var delete: @Sendable (URL) async throws -> Void
/// Check if a file exists at the given location.
public var fileExists: @Sendable (URL) -> Bool = { _ in true }
public var findVaultFileInCurrentDirectory: @Sendable () async throws -> URL?
/// Find an ansible-vault file in the given location, checking up to 1 level deep
/// in subfolders.
public var findVaultFile: @Sendable (URL) async throws -> URL?
/// Return the user's home directory.
public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") }
/// Check if an item is a directory or not.
public var isDirectory: @Sendable (URL) async throws -> Bool
/// Load a file from the given location.
public var load: @Sendable (URL) async throws -> Data
/// Write data to a file at the given location.
public var write: @Sendable (Data, URL) async throws -> Void
/// Find an ansible-vault file in the current directory, checking up to 1 level
/// deep in subfolders.
public func findVaultFileInCurrentDirectory() async throws -> URL? {
try await findVaultFile(URL(filePath: "./"))
}
}
extension FileClient: DependencyKey {
@@ -26,23 +57,17 @@ extension FileClient: DependencyKey {
public static var liveValue: Self {
let manager = LiveFileClient()
return .init {
try await manager.copy($0, to: $1)
} createDirectory: {
try await manager.creatDirectory($0)
} fileExists: { url in
manager.fileExists(at: url)
} findVaultFileInCurrentDirectory: {
try await manager.findVaultFileInCurrentDirectory()
} homeDirectory: {
manager.homeDirectory()
} isDirectory: {
manager.isDirectory($0)
} load: { url in
try await manager.load(from: url)
} write: { data, url in
try await manager.write(data, to: url)
}
return .init(
copy: { try await manager.copy($0, to: $1) },
createDirectory: { try await manager.creatDirectory($0) },
delete: { try await manager.delete($0) },
fileExists: { manager.fileExists(at: $0) },
findVaultFile: { try await manager.findVaultFile(in: $0) },
homeDirectory: { manager.homeDirectory() },
isDirectory: { manager.isDirectory($0) },
load: { try await manager.load(from: $0) },
write: { try await manager.write($0, to: $1) }
)
}
}
@@ -58,30 +83,35 @@ struct LiveFileClient: Sendable {
try manager.createDirectory(at: url, withIntermediateDirectories: true)
}
func delete(_ url: URL) async throws {
try manager.removeItem(at: url)
}
func fileExists(at url: URL) -> Bool {
manager.fileExists(atPath: url.cleanFilePath)
}
func findVaultFileInCurrentDirectory() async throws -> URL? {
let urls = try manager.contentsOfDirectory(
at: URL(filePath: "./"),
includingPropertiesForKeys: nil
)
func findVaultFile(in url: URL) async throws -> URL? {
guard isDirectory(url) else { return nil }
let urls = try manager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
// Check the current directory
if let vault = urls.firstVaultFile { return vault }
guard let vault = urls.firstVaultFile else {
// check subfolders, 1 layer deep.
let subfolders = urls.filter { isDirectory($0) }
for folder in subfolders {
let vault = try manager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: nil
)
.firstVaultFile
let subfolders = urls.filter { isDirectory($0) }
for folder in subfolders {
let files = try manager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: nil
)
if let vault = files.firstVaultFile { return vault }
if let vault { return vault }
}
// Didn't find a file.
return nil
}
return nil
return vault
}
func homeDirectory() -> URL {
@@ -114,6 +144,8 @@ private extension Array where Element == URL {
public extension URL {
var cleanFilePath: String {
absoluteString.replacing("file://", with: "")
absoluteString
.replacing("file://", with: "")
.replacing("/private", with: "")
}
}

View File

@@ -0,0 +1,9 @@
extension PandocClient {
/// Represents constant string values needed internally.
enum Constants {
static let pandocCommand = "pandoc"
static let defaultOutputFileName = "Report"
static let defaultPdfEngine = "xelatex"
}
}

View File

@@ -0,0 +1,245 @@
import CommandClient
import ConfigurationClient
import Dependencies
import Foundation
import PlaybookClient
extension PandocClient.RunOptions {
func run(
_ fileType: PandocClient.FileType,
_ environment: [String: String]
) 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 ?? environment["PWD"]
guard let projectDirectory else {
throw ProjectDirectoryNotSpecified()
}
if shouldBuildProject {
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
)
}
}
extension PandocClient.FileType {
var fileExtension: String {
switch self {
case .html: return "html"
case .latex: return "tex"
case .pdf: return "pdf"
}
}
}
@_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 = ".\(outputFileType.fileExtension)"
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 {}

View File

@@ -0,0 +1,141 @@
import CommandClient
import ConfigurationClient
import Dependencies
import DependenciesMacros
import Foundation
public extension DependencyValues {
/// Represents interactions with the `pandoc` command line application.
///
/// The `pandoc` command line application is used to generate the final output
/// documents from a home performance assessment project.
///
var pandocClient: PandocClient {
get { self[PandocClient.self] }
set { self[PandocClient.self] = newValue }
}
}
@DependencyClient
public struct PandocClient: Sendable {
/// Run a pandoc command.
public var run: Run
/// Represents the pandoc commands that we can run.
@DependencyClient
public struct Run: Sendable {
/// Generate a latex file from the given options, returning the path the
/// generated file was written to.
public var generateLatex: @Sendable (RunOptions) async throws -> String
/// Generate an html file from the given options, returning the path the
/// generated file was written to.
public var generateHtml: @Sendable (RunOptions) async throws -> String
/// Generate a pdf file from the given options, returning the path the
/// generated file was written to.
public var generatePdf: @Sendable (RunOptions, String?) async throws -> String
/// Generate a pdf file from the given options, returning the path the
/// generated file was written to.
///
/// - Parameters:
/// - options: The shared run options.
/// - pdfEngine: The pdf-engine to use to generate the pdf file.
public func generatePdf(
_ options: RunOptions,
pdfEngine: String? = nil
) async throws -> String {
try await generatePdf(options, pdfEngine)
}
}
/// Represents the shared options used to run a `pandoc` command.
///
///
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 shouldBuildProject: Bool
/// Create the shared run options.
///
/// - Parameters:
/// - buildDirectory: Specify the build directory of the project.
/// - extraOptions: Extra arguments / options passed to the `pandoc` command.
/// - file: Files used to build the final output.
/// - loggingOptions: The logging options used when running the command.
/// - includeInHeader: Files to include in the header of the final output document.
/// - outputDirectory: Optional output directory for the output file, defaults to current working directory.
/// - projectDirectory: Optional project directory, defaults to current working directory.
/// - outputFileName: Override the output file name.
/// - quiet: Don't log output from the command.
/// - shell: Optional shell to use when calling the pandoc command.
/// - shouldBuildProject: Build the project prior to generating the output file.
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 shouldBuildProject: 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.shouldBuildProject = shouldBuildProject
}
}
/// Represents the file types that we can generate output files for.
@_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 func live(environment: [String: String]) -> PandocClient {
.init(
run: Run(
generateLatex: { try await $0.run(.latex, environment) },
generateHtml: { try await $0.run(.html, environment) },
generatePdf: { try await $0.run(.pdf(engine: $1), environment) }
)
)
}
public static var liveValue: PandocClient {
.live(environment: ProcessInfo.processInfo.environment)
}
}

View File

@@ -1,4 +1,6 @@
// TODO: Use an actuall version tag for playbook repo.
// TODO: Use an actual version tag for playbook repo.
// TODO: Use an externally public url for the playbook repo.
public extension PlaybookClient {
@_spi(Internal)
enum Constants {

View File

@@ -9,6 +9,11 @@ import ShellClient
// TODO: Add update checks and pull for the playbook.
public extension DependencyValues {
/// Manages interactions with the playbook repository as well as `ansible-playbook`
/// command line application.
///
///
var playbookClient: PlaybookClient {
get { self[PlaybookClient.self] }
set { self[PlaybookClient.self] = newValue }
@@ -17,21 +22,36 @@ public extension DependencyValues {
@DependencyClient
public struct PlaybookClient: Sendable {
/// Manages interactions with the playbook repository.
public var repository: Repository
/// Run the `ansible-playbook` command line application.
public var run: RunPlaybook
}
public extension PlaybookClient {
/// Manages interactions with the `ansible-hpa-playbook` repository, which is
/// used to build and generate home performance assessment projects.
@DependencyClient
struct Repository: Sendable {
/// Install the repository based on the given configuration. If configuration is
/// not supplied then the default location for the playbook repository is
/// `~/.local/share/hpa/playbook`
public var install: @Sendable (Configuration?) async throws -> Void
/// Get the current directory of the playbook repository based on the given
/// configuration.
public var directory: @Sendable (Configuration?) async throws -> String
/// Install the playbook in the default location.
public func install() async throws {
try await install(nil)
}
/// Get the current directory path of the playbook repository.
public func directory() async throws -> String {
try await directory(nil)
}
@@ -40,23 +60,56 @@ public extension PlaybookClient {
public extension PlaybookClient {
/// Runs the `ansible-playbook` command line application with the `ansible-hpa-playbook`.
///
/// This is used to build and create home performance projects. It can also generate a
/// template for a user to customize to their use case.
@DependencyClient
struct RunPlaybook: Sendable {
/// Build a home performance assesment project with the given options.
public var buildProject: @Sendable (BuildOptions) async throws -> Void
/// Create a new home performance assesment project with the given options.
public var createProject: @Sendable (CreateOptions, JSONEncoder?) async throws -> Void
/// Generate a user's template from the default home performance template.
public var generateTemplate: @Sendable (GenerateTemplateOptions) async throws -> String
/// Create a new home performance assesment project with the given options.
///
/// - Parameters:
/// - options: The options used to create the project.
public func createProject(_ options: CreateOptions) async throws {
try await createProject(options, nil)
}
/// Represents options that are shared for all the `ansible-playbook` commands.
public struct SharedRunOptions: Equatable, Sendable {
public let extraOptions: [String]?
public let inventoryFilePath: String?
public let loggingOptions: LoggingOptions
public let quiet: Bool
public let shell: String?
/// Extra arguments / options passed to the `ansible-playbook` command.
let extraOptions: [String]?
/// Specify the inventory file path.
let inventoryFilePath: String?
/// The logging options used when running the command.
let loggingOptions: LoggingOptions
/// Disables log output of the command.
let quiet: Bool
/// Optional shell to use when running the command.
let shell: String?
/// Create the shared options.
///
/// - Parameters:
/// - extraOptions: The extra arguments / options to pass to the command.
/// - inventoryFilePath: Specify the inventory file path.
/// - loggingOptions: The logging options used when running the command.
/// - quiet: Disable log output from the command.
/// - shell: Specify a shell used when running the command.
public init(
extraOptions: [String]? = nil,
inventoryFilePath: String? = nil,
@@ -72,11 +125,21 @@ public extension PlaybookClient {
}
}
/// Options require when calling the build project command.
@dynamicMemberLookup
public struct BuildOptions: Equatable, Sendable {
public let projectDirectory: String?
public let shared: SharedRunOptions
/// An optional project directory, if not supplied then we will use the current working directory.
let projectDirectory: String?
/// The shared run options.
let shared: SharedRunOptions
/// Create new build options.
///
/// - Parameters:
/// - projectDirectory: The optional project directory to build, if not supplied then we'll use the current working directory.
/// - shared: The shared run options.
public init(
projectDirectory: String? = nil,
shared: SharedRunOptions
@@ -90,13 +153,25 @@ public extension PlaybookClient {
}
}
/// Options required when creating a new home performance assessment project.
@dynamicMemberLookup
public struct CreateOptions: Equatable, Sendable {
public let projectDirectory: String
public let shared: SharedRunOptions
public let template: Configuration.Template?
public let useLocalTemplateDirectory: Bool
/// The directory to generate the new project in.
let projectDirectory: String
/// Shared run options.
let shared: SharedRunOptions
/// Custom template configuration to use.
let template: Configuration.Template?
/// Specify whether we should only use a local template directory to create the project from.
let useLocalTemplateDirectory: Bool
/// Create new create options.
///
/// - Parameters:
/// - projectDirectory: The directory to generate the project in.
/// - shared: The shared run options.
/// - template: Custom template configuration used when generating the project.
/// - useLocalTemplateDirectory: Whether to use a local template directory, not a template repository.
public init(
projectDirectory: String,
shared: SharedRunOptions,
@@ -114,13 +189,25 @@ public extension PlaybookClient {
}
}
/// Options required when generating a new template repository / directory.
@dynamicMemberLookup
public struct GenerateTemplateOptions: Equatable, Sendable {
public let shared: SharedRunOptions
public let templateDirectory: String
public let templateVarsDirectory: String?
public let useVault: Bool
/// The shared run options.
let shared: SharedRunOptions
/// The path to generate the template in.
let templateDirectory: String
/// Specify the name of the directory for template variables.
let templateVarsDirectory: String?
/// Specify whether to use `ansible-vault` encrypted variables.
let useVault: Bool
/// Create new generate template options.
///
/// - Parameters:
/// - shared: The shared run options
/// - templateDirectory: The path to generate the template in.
/// - templateVarsDirectory: Specify the name of the directory for template variables.
/// - useVault: Specify wheter to use `ansible-vault` encrypted variables.
public init(
shared: SharedRunOptions,
templateDirectory: String,

View File

@@ -4,9 +4,8 @@ import Dependencies
import DependenciesMacros
import FileClient
// TODO: Add edit / view routes, possibly create?
public extension DependencyValues {
/// Manages interactions with `ansible-vault` command line application.
var vaultClient: VaultClient {
get { self[VaultClient.self] }
set { self[VaultClient.self] = newValue }
@@ -15,23 +14,41 @@ public extension DependencyValues {
@DependencyClient
public struct VaultClient: Sendable {
/// Run an `ansible-vault` command.
public var run: Run
@DependencyClient
public struct Run: Sendable {
/// Decrypt an `ansible-vault` file.
public var decrypt: @Sendable (RunOptions) async throws -> String
/// Encrypt an `ansible-vault` file.
public var encrypt: @Sendable (RunOptions) async throws -> String
}
public struct RunOptions: Equatable, Sendable {
public let extraOptions: [String]?
public let loggingOptions: LoggingOptions
public let outputFilePath: String?
public let quiet: Bool
public let shell: String?
public let vaultFilePath: String?
/// Extra arguments / options passed to the `ansible-vault` command.
let extraOptions: [String]?
/// Logging options to use while running the command.
let loggingOptions: LoggingOptions
/// The optional output file path.
let outputFilePath: String?
/// Disable logging output.
let quiet: Bool
/// Optional shell used to call the command.
let shell: String?
/// The path to the vault file, if not supplied we will search the current directory for it.
let vaultFilePath: String?
/// Create new run options.
///
/// - Parameters:
/// - extraOptions: Extra arguments / options passed to the command.
/// - loggingOptions: The logging options used while running the command.
/// - outputFilePath: The optional output file path.
/// - quiet: Disable logging output of the command.
/// - shell: The shell used to call the command.
/// - vaultFilePath: The vault file, if not supplied we will search in the current working directory.
public init(
extraOptions: [String]? = nil,
loggingOptions: LoggingOptions,
@@ -51,8 +68,8 @@ public struct VaultClient: Sendable {
@_spi(Internal)
public enum Route: String, Equatable, Sendable {
case encrypt
case decrypt
case encrypt
public var verb: String { rawValue }
}
@@ -65,12 +82,8 @@ extension VaultClient: DependencyKey {
public static var liveValue: VaultClient {
.init(
run: .init(
decrypt: {
try await $0.run(route: .decrypt)
},
encrypt: {
try await $0.run(route: .encrypt)
}
decrypt: { try await $0.run(route: .decrypt) },
encrypt: { try await $0.run(route: .encrypt) }
)
)
}

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
import PlaybookClient

View File

@@ -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
)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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 = """

View File

@@ -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,

View File

@@ -1,18 +0,0 @@
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
)
}
}

View File

@@ -0,0 +1,27 @@
import ArgumentParser
import CliDoc
import ConfigurationClient
import CustomDump
import Dependencies
struct DumpConfigCommand: AsyncParsableCommand {
static let commandName = "dump-config"
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: createAbstract("Show the current configuration."),
usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false, usesExtraArguments: false),
discussion: Discussion {
"Useful to debug your configuration settings / make sure they load properly."
}
)
func run() async throws {
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad()
customDump(configuration)
}
}

View File

@@ -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)
}
}
}

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
import PlaybookClient

View File

@@ -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()
}
}

View File

@@ -10,6 +10,7 @@ struct UtilsCommand: AsyncParsableCommand {
These are commands that are generally only run on occasion / less frequently used.
""",
subcommands: [
DumpConfigCommand.self,
GenerateProjectTemplateCommand.self,
GenerateConfigurationCommand.self,
InstallDependenciesCommand.self

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
import VaultClient

View File

@@ -0,0 +1 @@

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
import VaultClient

View File

@@ -24,7 +24,7 @@ struct VaultCommand: AsyncParsableCommand {
.separator(.newLine(count: 2))
},
subcommands: [
EncryptCommand.self, DecryptCommand.self
DecryptCommand.self, EncryptCommand.self
]
)
}

View File

@@ -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(

View File

@@ -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
}()

View File

@@ -18,6 +18,7 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json"])
func generateConfigFile(fileName: String) async throws {
try await withTestLogger(key: "generateConfigFile") {
$0.coders = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -26,13 +27,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))
@@ -44,6 +54,7 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json", nil])
func loadConfigFile(fileName: String?) async throws {
try await withTestLogger(key: "generateConfigFile") {
$0.coders = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -130,6 +141,27 @@ struct ConfigurationClientTests: TestCase {
}
}
}
@Test
func writeCreatesBackupFile() async throws {
try await withDependencies {
$0.fileClient = .liveValue
} operation: {
let client = ConfigurationClient.liveValue
try await withGeneratedConfigFile(named: "config.toml", client: client) { configFile in
@Dependency(\.fileClient) var fileClient
let backupUrl = configFile.url.appendingPathExtension(".back")
#expect(fileClient.fileExists(backupUrl) == false)
let config = Configuration()
try await client.write(config, to: configFile)
#expect(fileClient.fileExists(backupUrl))
}
}
}
}
func generateFindEnvironments(file: File) -> [[String: String]] {
@@ -150,7 +182,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 +203,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)
}
}

View File

@@ -0,0 +1,67 @@
import FileClient
import Foundation
import Testing
import TestSupport
@Suite("FileClientTests")
struct FileClientTests {
@Test
func createDirectory() async throws {
try await withTemporaryDirectory { url in
let fileClient = FileClient.liveValue
let tempDir = url.appending(path: "temp")
try await fileClient.createDirectory(tempDir)
let isDirectory = try await fileClient.isDirectory(tempDir)
#expect(isDirectory)
}
}
@Test(arguments: ["vault.yml", "vault.yaml"])
func findVaultFile(fileName: String) async throws {
try await withTemporaryDirectory { url in
let fileClient = FileClient.liveValue
let vaultFilePath = url.appending(path: fileName)
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
let output = try await fileClient.findVaultFile(url)!
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
let nilWhenFileNotDirectory = try await fileClient.findVaultFile(vaultFilePath)
#expect(nilWhenFileNotDirectory == nil)
}
}
@Test(arguments: ["vault.yml", "vault.yaml"])
func findVaultFileNestedInSubfolder(fileName: String) async throws {
try await withTemporaryDirectory { url in
let fileClient = FileClient.liveValue
let subDir = url.appending(path: "sub")
try await fileClient.createDirectory(subDir)
let vaultFilePath = subDir.appending(path: fileName)
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
let output = try await fileClient.findVaultFile(url)!
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
}
}
@Test
func findVaultFileReturnsNil() async throws {
try await withTemporaryDirectory { url in
let fileClient = FileClient.liveValue
let subDir = url.appending(path: "sub")
try await fileClient.createDirectory(subDir)
let output = try await fileClient.findVaultFile(url)
#expect(output == nil)
}
}
}

View File

@@ -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)
)
)
]
}

View File

@@ -22,12 +22,14 @@ struct PlaybookClientTests: TestCase {
@Test(.tags(.repository))
func repositoryInstallation() async throws {
try await withDependencies {
try await withTestLogger(key: "repositoryInstallation") {
$0.fileClient = .liveValue
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
} operation: {
try await withTemporaryDirectory { tempDirectory in
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
let pathUrl = tempDirectory.appending(path: "playbook")
let playbookClient = PlaybookClient.liveValue
@@ -35,7 +37,8 @@ struct PlaybookClientTests: TestCase {
try? FileManager.default.removeItem(at: pathUrl)
try await playbookClient.repository.install(configuration)
let exists = FileManager.default.fileExists(atPath: pathUrl.cleanFilePath)
logger.debug("Done cloning playbook")
let exists = try await fileClient.isDirectory(pathUrl)
#expect(exists)
}
}
@@ -61,8 +64,6 @@ struct PlaybookClientTests: TestCase {
try await withMockConfiguration(captured, key: "runBuildProject") {
@Dependency(\.playbookClient) var playbookClient
let configuration = Configuration.mock
try await playbookClient.run.buildProject(.init(projectDirectory: "/foo", shared: Self.sharedRunOptions))
let arguments = await captured.options!.arguments
@@ -92,8 +93,6 @@ struct PlaybookClientTests: TestCase {
@Dependency(\.logger) var logger
@Dependency(\.playbookClient) var playbookClient
let configuration = Configuration.mock
try await playbookClient.run.createProject(
.init(
projectDirectory: "/project",

View File

@@ -14,7 +14,7 @@ struct VaultClientTests: TestCase {
func decrypt(input: TestOptions) async throws {
try await withCapturingCommandClient("decrypt") {
$0.configurationClient = .mock(input.configuration)
$0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "/vault.yml") }
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
$0.vaultClient = .liveValue
} run: {
@Dependency(\.vaultClient) var vaultClient
@@ -45,7 +45,7 @@ struct VaultClientTests: TestCase {
func encrypt(input: TestOptions) async throws {
try await withCapturingCommandClient("decrypt") {
$0.configurationClient = .mock(input.configuration)
$0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "/vault.yml") }
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
$0.vaultClient = .liveValue
} run: {
@Dependency(\.vaultClient) var vaultClient

23
docker/Dockerfile Executable file
View File

@@ -0,0 +1,23 @@
# Used this to build the release version of the image.
# Build the executable
ARG SWIFT_IMAGE_VERSION="6.0.3"
FROM swift:${SWIFT_IMAGE_VERSION} AS build
WORKDIR /build
COPY ./Package.* ./
RUN swift package resolve
COPY . .
RUN swift build -c release -Xswiftc -g
# Run image
FROM swift:${SWIFT_IMAGE_VERSION}-slim
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && apt-get -q update && \
apt-get -q install -y \
ansible \
pandoc \
texlive \
&& rm -r /var/lib/apt/lists/*
COPY --from=build /build/.build/release/hpa /usr/local/bin
CMD ["/bin/bash", "-xc", "/usr/local/bin/hpa"]

10
docker/Dockerfile.test Normal file
View File

@@ -0,0 +1,10 @@
# Used to build a test image.
ARG SWIFT_IMAGE_VERSION="6.0.3"
FROM swift:${SWIFT_IMAGE_VERSION}
WORKDIR /app
COPY ./Package.* ./
RUN swift package resolve
COPY . .
RUN swift build
CMD ["/bin/bash", "-xc", "swift", "test"]

View File

@@ -1,14 +1,30 @@
docker_image_name := "swift-hpa"
install_path := "~/.local/share/bin/hpa"
completion_path := "~/.local/share/zsh/completions/_hpa"
build mode="debug":
swift build -c {{mode}}
alias b := build
build-docker file="Dockerfile" tag="latest":
@docker build \
--file docker/{{file}} \
--tag {{docker_image_name}}:{{tag}} .
build-docker-test: (build-docker "Dockerfile.test" "test")
test *ARGS:
swift test {{ARGS}}
alias t := test
test-docker *ARGS: (build-docker-test)
@docker run --rm \
--network host \
{{docker_image_name}}:test \
swift test {{ARGS}}
run *ARGS:
swift run hpa {{ARGS}}
@@ -23,3 +39,7 @@ update-version:
--allow-writing-to-package-directory \
update-version \
hpa
install: (build "release")
@cp .build/release/hpa {{install_path}}
@{{install_path}} --generate-completion-script zsh > {{completion_path}}