16 Commits

Author SHA1 Message Date
357eecabf9 feat: Updates tests for html generation, adds some more documentation strings.
All checks were successful
CI / Run Tests (pull_request) Successful in 4m25s
Build docker images / docker (pull_request) Successful in 7m31s
2025-11-17 10:19:11 -05:00
045f48348d feat: Fixes generation of html document, it now builds a latex document in the build directory that it then converts to html. Need to update tests. 2025-11-17 09:38:23 -05:00
8ee4e436aa feat: Adds /root/project directory and sets it as the working directory in docker container.
All checks were successful
CI / Run Tests (push) Successful in 1m25s
Build docker images / docker (push) Successful in 7m22s
Release / release (push) Successful in 9s
2025-11-13 15:22:48 -05:00
064976ed6e feat: Fixes entrypoint script that was not appropriately handling arguments to hpa script.
All checks were successful
CI / Run Tests (push) Successful in 2m31s
Build docker images / docker (push) Successful in 7m26s
2025-11-13 14:59:35 -05:00
45a1520a2b feat: Adds xelatex package to docker image.
All checks were successful
CI / Run Tests (push) Successful in 1m25s
Build docker images / docker (push) Successful in 7m21s
2025-11-07 17:02:50 -05:00
f7f3ac5dc7 feat: Updates Dockerfile, WIP
All checks were successful
CI / Run Tests (push) Successful in 1m27s
Build docker images / docker (push) Successful in 6m45s
2025-11-07 15:16:05 -05:00
15454e3686 feat: Adds entrypoint.sh script to docker to allow attaching to a shell inside the container.
All checks were successful
CI / Run Tests (push) Successful in 2m33s
Build docker images / docker (push) Successful in 6m49s
2025-11-07 13:18:57 -05:00
63012131d4 feat: Removes image creation on dev branch, only create for main branch.
All checks were successful
CI / Run Tests (pull_request) Successful in 1m25s
Build docker images / docker (pull_request) Successful in 6m44s
CI / Run Tests (push) Successful in 1m26s
Build docker images / docker (push) Successful in 6m48s
2025-11-04 14:09:30 -05:00
a013e6fe81 feat: Adds docker ci workflow. 2025-11-04 14:09:30 -05:00
9d8f3368ec feat: Adds todos, updates Dockerfile to use hpa as the entrypoint. 2025-11-04 14:09:30 -05:00
88a1f181cb feat: Updates Dockerfile, WIP 2025-11-04 14:09:30 -05:00
a6227a80db feat: Updates release notes. 2025-11-04 14:09:29 -05:00
d1a47e2ac6 feat: Updates to check if playbook is installed prior to running any of the commands, updates tests. 2025-11-04 14:09:29 -05:00
86dc084e7d feat: Documenting release workflow. 2025-11-04 14:09:29 -05:00
d3a9aa2f00 feat: More readme, also removes ci from dev branch and only runs for pulls and pushes to main. 2025-11-04 14:08:29 -05:00
1820988894 feat: Fix merge conflicts. 2025-11-04 14:06:33 -05:00
15 changed files with 418 additions and 113 deletions

View File

@@ -0,0 +1,55 @@
name: Build docker images
on:
push:
branches:
- main
pull_request: {}
workflow_dispatch: {}
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup docker buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registery
uses: docker/login-action@v3
with:
registry: git.housh.dev
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: git.housh.dev/michael/swift-hpa
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -21,7 +21,7 @@ brew install michael/formula/hpa
Installation on platforms other than `macOS` are currently being worked on, along with support for Installation on platforms other than `macOS` are currently being worked on, along with support for
running in a `docker` container. running in a `docker` container.
### Ensuring dependencies are installed. ### Ensuring dependencies are installed
This application requires some dependencies to be installed on your system, you can install the This application requires some dependencies to be installed on your system, you can install the
dependencies with the following command. dependencies with the following command.
@@ -44,7 +44,7 @@ repositories, and encrypt / decrypt variable files. The playbook get's installed
> NOTE: All commands accept a `--help` option which will display the arguments and options a command > NOTE: All commands accept a `--help` option which will display the arguments and options a command
> can use, along with example usage of the commands. > can use, along with example usage of the commands.
### Configure the application. ### Configure the application
When you first download the application you can setup the configuration file for your use case. When you first download the application you can setup the configuration file for your use case.
@@ -76,7 +76,7 @@ allows your template to expand over time.
Once your template is setup, make sure that your configuration file is setup to point to your Once your template is setup, make sure that your configuration file is setup to point to your
customized template. customized template.
## Creating a project. ## Creating a project
The first step after having your template defined is to create a project that uses it. The below The first step after having your template defined is to create a project that uses it. The below
command will create a template in the `~/consults/my-first-consult` directory. command will create a template in the `~/consults/my-first-consult` directory.
@@ -99,7 +99,7 @@ Or if your configuration has `directory` set in the `template` section.
hpa create --use-local-template ~/consults/my-first-consult hpa create --use-local-template ~/consults/my-first-consult
``` ```
## Generating output files. ## Generating output files
Once you have created a project and edited the contents to your liking. You can then generate the Once you have created a project and edited the contents to your liking. You can then generate the
final output file (typically a pdf) that can be sent to your customer. final output file (typically a pdf) that can be sent to your customer.
@@ -122,7 +122,7 @@ Currently the supported output file types are:
1. LaTeX 1. LaTeX
1. HTML 1. HTML
## Build command. ## Build command
The command line tool goes through an intermediate step when generating output, which is called The command line tool goes through an intermediate step when generating output, which is called
`build`. The build step generates the final output files using defined variables that are located in `build`. The build step generates the final output files using defined variables that are located in
@@ -147,7 +147,7 @@ like to generate output for.
hpa build --project-directory ~/consults/my-first-consult hpa build --project-directory ~/consults/my-first-consult
``` ```
## Some General Usage Notes: ## Some General Usage Notes
There is often a lot of output to the console when running commands, which can be problematic if you There is often a lot of output to the console when running commands, which can be problematic if you
want to pipe the output into other command line tools, so all options accept a `-q | --quiet` flag want to pipe the output into other command line tools, so all options accept a `-q | --quiet` flag

24
Release.md Normal file
View File

@@ -0,0 +1,24 @@
# Release Workflow Steps
This is a reminder of the steps used to create a release and update the homebrew formula.
> Note: These steps apply to the version hosted on `gitea`, on `github` more of these steps can be
> automated in `ci`, but there are no `macOS` host runners currently in `gitea`, so the bottles need
> built on `macOS`.
1. Update the version in `Sources/hpa/Version.swift`.
1. Tag the commit with the next version tag.
1. Push the tagged commit, this will initiate the release being created.
1. Get the `sha` of the `*.tar.gz` in the release.
1. `just get-release-sha`
1. Update the homebrew formula url, sha256, and version at top of the homebrew formula.
1. `cd $(brew --repo michael/formula)`
1. Build and generate a homebrew bottle.
1. `just bottle`
1. Update the `bottle do` section of homebrew formula with output during previous step.
1. Also make sure the `root_url` in the bottle section points to the new release.
1. Upload the bottle `*.tar.gz` file that was created to the release.
1. Generate a pull-request to the formula repo.
1. Generate a pull-request to this repo to merge into main.
1. Remove the bottle from current directory.
1. `just remove-bottles`

View File

@@ -4,10 +4,10 @@ import DependenciesMacros
import Foundation import Foundation
import ShellClient import ShellClient
public extension DependencyValues { extension DependencyValues {
/// Runs shell commands. /// Runs shell commands.
var commandClient: CommandClient { public var commandClient: CommandClient {
get { self[CommandClient.self] } get { self[CommandClient.self] }
set { self[CommandClient.self] = newValue } set { self[CommandClient.self] = newValue }
} }
@@ -67,12 +67,13 @@ public struct CommandClient: Sendable {
in workingDirectory: String? = nil, in workingDirectory: String? = nil,
_ arguments: [String] _ arguments: [String]
) async throws { ) async throws {
try await runCommand(.init( try await runCommand(
arguments: arguments, .init(
quiet: quiet, arguments: arguments,
shell: shell, quiet: quiet,
workingDirectory: workingDirectory shell: shell,
)) workingDirectory: workingDirectory
))
} }
/// Runs a shell command. /// Runs a shell command.
@@ -161,19 +162,21 @@ extension CommandClient: DependencyKey {
.init { options in .init { options in
@Dependency(\.asyncShellClient) var shellClient @Dependency(\.asyncShellClient) var shellClient
if !options.quiet { if !options.quiet {
try await shellClient.foreground(.init( try await shellClient.foreground(
shell: .init(options.shell), .init(
environment: environment, shell: .init(options.shell),
in: options.workingDirectory, environment: environment,
options.arguments in: options.workingDirectory,
)) options.arguments
))
} else { } else {
try await shellClient.background(.init( try await shellClient.background(
shell: .init(options.shell), .init(
environment: environment, shell: .init(options.shell),
in: options.workingDirectory, environment: environment,
options.arguments in: options.workingDirectory,
)) options.arguments
))
} }
} }
} }
@@ -184,12 +187,12 @@ extension CommandClient: DependencyKey {
} }
@_spi(Internal) @_spi(Internal)
public extension CommandClient { extension CommandClient {
/// Create a command client that can capture the arguments / options. /// Create a command client that can capture the arguments / options.
/// ///
/// This is used for testing. /// This is used for testing.
static func capturing(_ client: CapturingClient) -> Self { public static func capturing(_ client: CapturingClient) -> Self {
.init { options in .init { options in
await client.set(options) await client.set(options)
} }
@@ -198,7 +201,7 @@ public extension CommandClient {
/// Captures the arguments / options passed into the command client's run commands. /// Captures the arguments / options passed into the command client's run commands.
/// ///
@dynamicMemberLookup @dynamicMemberLookup
actor CapturingClient: Sendable { public actor CapturingClient: Sendable {
public private(set) var options: RunCommandOptions? public private(set) var options: RunCommandOptions?
public init() {} public init() {}

View File

@@ -120,7 +120,11 @@ struct LiveFileClient: Sendable {
func isDirectory(_ url: URL) -> Bool { func isDirectory(_ url: URL) -> Bool {
var isDirectory: ObjCBool = false var isDirectory: ObjCBool = false
manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory) #if os(Linux)
_ = manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory)
#else
manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory)
#endif
return isDirectory.boolValue return isDirectory.boolValue
} }

View File

@@ -6,26 +6,72 @@ import PlaybookClient
extension PandocClient.RunOptions { extension PandocClient.RunOptions {
/// Runs a pandoc conversion on the project generating the given file type.
///
/// - Parameters:
/// - fileType: The file type to convert to.
/// - environment: The environment variables.
///
/// - Returns: File path to the converted output file.
func run( func run(
_ fileType: PandocClient.FileType, _ fileType: PandocClient.FileType,
_ environment: [String: String] _ environment: [String: String]
) async throws -> String {
@Dependency(\.logger) var logger
let ensuredOptions = try await self.ensuredOptions(fileType)
let projectDirectory = self.projectDirectory ?? environment["PWD"]
guard let projectDirectory else {
throw ProjectDirectoryNotSpecified()
}
try await buildProject(projectDirectory, ensuredOptions)
let outputDirectory = self.outputDirectory ?? projectDirectory
let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredFilename)"
let arguments = ensuredOptions.makeArguments(
outputPath: outputPath,
projectDirectory: projectDirectory
)
logger.debug("Pandoc arguments: \(arguments)")
return try await runCommand(arguments, outputPath)
}
/// Runs a shell command with the given arguments, returning the passed in output path
/// so the command can be chained, if needed.
///
@discardableResult
func runCommand(
_ arguments: [String],
_ outputPath: String
) async throws -> String { ) async throws -> String {
@Dependency(\.commandClient) var commandClient @Dependency(\.commandClient) var commandClient
@Dependency(\.logger) var logger
logger.debug("Running shell command with arguments: \(arguments)")
return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) {
(arguments, outputPath)
}
}
/// Build the project if necessary, before running the shell command that builds the final
/// output file(s).
///
func buildProject(
_ projectDirectory: String,
_ ensuredOptions: EnsuredPandocOptions
) async throws {
@Dependency(\.logger) var logger @Dependency(\.logger) var logger
@Dependency(\.playbookClient) var playbookClient @Dependency(\.playbookClient) var playbookClient
return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) { if shouldBuildProject {
let ensuredOptions = try await self.ensuredOptions(fileType) logger.debug("Building project...")
try await playbookClient.run.buildProject(
let projectDirectory = self.projectDirectory ?? environment["PWD"] .init(
guard let projectDirectory else {
throw ProjectDirectoryNotSpecified()
}
if shouldBuildProject {
logger.debug("Building project...")
try await playbookClient.run.buildProject(.init(
projectDirectory: projectDirectory, projectDirectory: projectDirectory,
shared: .init( shared: .init(
extraOptions: nil, extraOptions: nil,
@@ -34,23 +80,28 @@ extension PandocClient.RunOptions {
quiet: quiet, quiet: quiet,
shell: shell shell: shell
) )
)) )
} )
}
let outputDirectory = self.outputDirectory ?? projectDirectory // Build latex file pre-html, so that we can properly convert the latex document
let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)" // into an html document.
if ensuredOptions.outputFileType == .html {
let arguments = ensuredOptions.makeArguments( logger.debug("Building latex, pre-html conversion...")
let outputPath = "\(ensuredOptions.buildDirectory)/\(EnsuredPandocOptions.latexFilename)"
let arguments = ensuredOptions.preHtmlLatexOptions.makeArguments(
outputPath: outputPath, outputPath: outputPath,
projectDirectory: projectDirectory projectDirectory: projectDirectory
) )
try await runCommand(arguments, outputPath)
logger.debug("Pandoc arguments: \(arguments)")
return (arguments, outputPath)
} }
} }
/// Generates the ensured/parsed options for a pandoc conversion.
///
/// - Parameter fileType: The file type we're converting to.
///
/// - Returns: The ensured options.
func ensuredOptions( func ensuredOptions(
_ fileType: PandocClient.FileType _ fileType: PandocClient.FileType
) async throws -> EnsuredPandocOptions { ) async throws -> EnsuredPandocOptions {
@@ -69,6 +120,7 @@ extension PandocClient.RunOptions {
} }
extension PandocClient.FileType { extension PandocClient.FileType {
/// Represents the appropriate file extension for a file type.
var fileExtension: String { var fileExtension: String {
switch self { switch self {
case .html: return "html" case .html: return "html"
@@ -78,8 +130,14 @@ extension PandocClient.FileType {
} }
} }
/// Represents pandoc options that get parsed based on the given run options, configuration, etc.
///
/// This set's potentially optional values into real values that are required for pandoc to run
/// properly and convert files for the given file type conversion.
@_spi(Internal) @_spi(Internal)
public struct EnsuredPandocOptions: Equatable, Sendable { public struct EnsuredPandocOptions: Equatable, Sendable {
public static let latexFilename = "Report.tex"
public let buildDirectory: String public let buildDirectory: String
public let extraOptions: [String]? public let extraOptions: [String]?
public let files: [String] public let files: [String]
@@ -88,7 +146,9 @@ public struct EnsuredPandocOptions: Equatable, Sendable {
public let outputFileType: PandocClient.FileType public let outputFileType: PandocClient.FileType
public let pdfEngine: String? public let pdfEngine: String?
public var ensuredExtensionFileName: String { /// Ensures the output filename includes the file extension, so that pandoc
/// can properly convert the files.
public var ensuredFilename: String {
let extensionString = ".\(outputFileType.fileExtension)" let extensionString = ".\(outputFileType.fileExtension)"
if !outputFileName.hasSuffix(extensionString) { if !outputFileName.hasSuffix(extensionString) {
@@ -97,14 +157,33 @@ public struct EnsuredPandocOptions: Equatable, Sendable {
return outputFileName return outputFileName
} }
/// Generates the options required for building the latex file that is needed
/// to convert the project to an html output file.
var preHtmlLatexOptions: Self {
.init(
buildDirectory: buildDirectory,
extraOptions: extraOptions,
files: files,
includeInHeader: includeInHeader,
outputFileName: Self.latexFilename,
outputFileType: .latex,
pdfEngine: nil
)
}
/// Generate arguments for the pandoc shell command based on the parsed options
/// for a given conversion.
///
func makeArguments( func makeArguments(
outputPath: String, outputPath: String,
projectDirectory: String projectDirectory: String
) -> [String] { ) -> [String] {
var arguments = [PandocClient.Constants.pandocCommand] var arguments = [PandocClient.Constants.pandocCommand]
arguments += includeInHeader.map { if outputFileType != .html {
"--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)" arguments += includeInHeader.map {
"--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)"
}
} }
if let pdfEngine { if let pdfEngine {
@@ -117,8 +196,12 @@ public struct EnsuredPandocOptions: Equatable, Sendable {
arguments.append(contentsOf: extraOptions) arguments.append(contentsOf: extraOptions)
} }
arguments += files.map { if outputFileType != .html {
"\(projectDirectory)/\(buildDirectory)/\($0)" arguments += files.map {
"\(projectDirectory)/\(buildDirectory)/\($0)"
}
} else {
arguments.append("\(projectDirectory)/\(buildDirectory)/\(Self.latexFilename)")
} }
return arguments return arguments
@@ -145,15 +228,15 @@ public func ensurePandocOptions(
} }
@_spi(Internal) @_spi(Internal)
public extension PandocClient.FileType { extension PandocClient.FileType {
func parsePdfEngine( public func parsePdfEngine(
_ configuration: Configuration.Generate?, _ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate _ defaults: Configuration.Generate
) -> String? { ) -> String? {
switch self { switch self {
case .html, .latex: case .html, .latex:
return nil return nil
case let .pdf(engine: engine): case .pdf(let engine):
if let engine { if let engine {
return engine return engine
} else if let engine = configuration?.pdfEngine { } else if let engine = configuration?.pdfEngine {
@@ -168,8 +251,8 @@ public extension PandocClient.FileType {
} }
@_spi(Internal) @_spi(Internal)
public extension PandocClient.RunOptions { extension PandocClient.RunOptions {
func parseFiles( public func parseFiles(
_ configuration: Configuration.Generate?, _ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate _ defaults: Configuration.Generate
) -> [String] { ) -> [String] {
@@ -187,7 +270,7 @@ public extension PandocClient.RunOptions {
} }
} }
func parseIncludeInHeader( public func parseIncludeInHeader(
_ configuration: Configuration.Generate?, _ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate _ defaults: Configuration.Generate
) -> [String] { ) -> [String] {
@@ -205,7 +288,7 @@ public extension PandocClient.RunOptions {
} }
} }
func parseOutputFileName( public func parseOutputFileName(
_ configuration: Configuration.Generate?, _ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate _ defaults: Configuration.Generate
) -> String { ) -> String {
@@ -223,7 +306,7 @@ public extension PandocClient.RunOptions {
} }
} }
func parseBuildDirectory( public func parseBuildDirectory(
_ configuration: Configuration.Generate?, _ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate _ defaults: Configuration.Generate
) -> String { ) -> String {

View File

@@ -4,14 +4,14 @@ import Dependencies
import DependenciesMacros import DependenciesMacros
import Foundation import Foundation
public extension DependencyValues { extension DependencyValues {
/// Represents interactions with the `pandoc` command line application. /// Represents interactions with the `pandoc` command line application.
/// ///
/// The `pandoc` command line application is used to generate the final output /// The `pandoc` command line application is used to generate the final output
/// documents from a home performance assessment project. /// documents from a home performance assessment project.
/// ///
var pandocClient: PandocClient { public var pandocClient: PandocClient {
get { self[PandocClient.self] } get { self[PandocClient.self] }
set { self[PandocClient.self] = newValue } set { self[PandocClient.self] = newValue }
} }

View File

@@ -81,6 +81,8 @@ extension PlaybookClient.RunPlaybook.SharedRunOptions {
) async throws -> T { ) async throws -> T {
@Dependency(\.commandClient) var commandClient @Dependency(\.commandClient) var commandClient
try await ensurePlaybookExists()
return try await commandClient.run( return try await commandClient.run(
logging: loggingOptions, logging: loggingOptions,
quiet: quiet, quiet: quiet,
@@ -101,6 +103,17 @@ extension PlaybookClient.RunPlaybook.SharedRunOptions {
return (arguments, output) return (arguments, output)
} }
} }
private func ensurePlaybookExists() async throws {
@Dependency(\.fileClient) var fileClient
@Dependency(\.playbookClient.repository) var repository
let directory = try await repository.directory()
let exists = try await fileClient.isDirectory(URL(filePath: directory))
if !exists {
try await repository.install()
}
}
} }
@_spi(Internal) @_spi(Internal)

7
TODO.md Normal file
View File

@@ -0,0 +1,7 @@
# TODO
- [ ] Build docker images in ci.
- [ ] Generate documentation for docker usage.
- [ ] Generally need to create a local wrapper script to mount volumes.
- [ ] Completions can be installed / used with the wrapper script by calling
`docker run --it --rm <image> --generate-completion-script <shell> > /path/to/completions/on/local`

View File

@@ -23,7 +23,11 @@ struct FileClientTests {
let fileClient = FileClient.liveValue let fileClient = FileClient.liveValue
let vaultFilePath = url.appending(path: fileName) let vaultFilePath = url.appending(path: fileName)
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil) #if os(Linux)
_ = FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
#else
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
#endif
let output = try await fileClient.findVaultFile(url)! let output = try await fileClient.findVaultFile(url)!
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath) #expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
@@ -43,7 +47,11 @@ struct FileClientTests {
try await fileClient.createDirectory(subDir) try await fileClient.createDirectory(subDir)
let vaultFilePath = subDir.appending(path: fileName) let vaultFilePath = subDir.appending(path: fileName)
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil) #if os(Linux)
_ = FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
#else
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
#endif
let output = try await fileClient.findVaultFile(url)! let output = try await fileClient.findVaultFile(url)!
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath) #expect(output.cleanFilePath == vaultFilePath.cleanFilePath)

View File

@@ -1,8 +1,8 @@
@_spi(Internal) import ConfigurationClient @_spi(Internal) import ConfigurationClient
@_spi(Internal) import PandocClient @_spi(Internal) import PandocClient
import PlaybookClient import PlaybookClient
import Testing
import TestSupport import TestSupport
import Testing
@Suite("PandocClientTests") @Suite("PandocClientTests")
struct PandocClientTests: TestCase { struct PandocClientTests: TestCase {
@@ -13,12 +13,12 @@ struct PandocClientTests: TestCase {
static let expectedIncludeInHeaders = [ static let expectedIncludeInHeaders = [
"--include-in-header=/project/.build/head.tex", "--include-in-header=/project/.build/head.tex",
"--include-in-header=/project/.build/footer.tex" "--include-in-header=/project/.build/footer.tex",
] ]
static let expectedFiles = [ static let expectedFiles = [
"/project/.build/Report.md", "/project/.build/Report.md",
"/project/.build/Definitions.md" "/project/.build/Definitions.md",
] ]
static var sharedRunOptions: PandocClient.RunOptions { static var sharedRunOptions: PandocClient.RunOptions {
@@ -49,7 +49,8 @@ struct PandocClientTests: TestCase {
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).tex") #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).tex")
} assert: { output in } assert: { output in
let expected = ["pandoc"] let expected =
["pandoc"]
+ Self.expectedIncludeInHeaders + Self.expectedIncludeInHeaders
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).tex"] + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).tex"]
+ Self.expectedFiles + Self.expectedFiles
@@ -71,10 +72,11 @@ struct PandocClientTests: TestCase {
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).html") #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).html")
} assert: { output in } assert: { output in
let expected = ["pandoc"] let expected = [
+ Self.expectedIncludeInHeaders "pandoc",
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).html"] "--output=\(Self.outputDirectory)/\(Self.defaultFileName).html",
+ Self.expectedFiles "\(Self.projectDirectory)/.build/Report.tex",
]
#expect(output.arguments == expected) #expect(output.arguments == expected)
} }
@@ -83,7 +85,7 @@ struct PandocClientTests: TestCase {
@Test( @Test(
arguments: [ arguments: [
nil, nil,
"lualatex" "lualatex",
] ]
) )
func generatePdf(pdfEngine: String?) async throws { func generatePdf(pdfEngine: String?) async throws {
@@ -94,11 +96,13 @@ struct PandocClientTests: TestCase {
} run: { } run: {
@Dependency(\.pandocClient) var pandocClient @Dependency(\.pandocClient) var pandocClient
let output = try await pandocClient.run.generatePdf(Self.sharedRunOptions, pdfEngine: pdfEngine) let output = try await pandocClient.run.generatePdf(
Self.sharedRunOptions, pdfEngine: pdfEngine)
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).pdf") #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).pdf")
} assert: { output in } assert: { output in
let expected = ["pandoc"] let expected =
["pandoc"]
+ Self.expectedIncludeInHeaders + Self.expectedIncludeInHeaders
+ ["--pdf-engine=\(pdfEngine ?? "xelatex")"] + ["--pdf-engine=\(pdfEngine ?? "xelatex")"]
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).pdf"] + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).pdf"]
@@ -147,10 +151,18 @@ struct TestPdfEngine: Sendable {
static let testCases: [Self] = [ static let testCases: [Self] = [
.init(fileType: .html, expectedEngine: nil, configuration: .init(), defaults: .default), .init(fileType: .html, expectedEngine: nil, configuration: .init(), defaults: .default),
.init(fileType: .latex, 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(
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .default), fileType: .pdf(engine: "lualatex"), expectedEngine: "lualatex", configuration: .init(),
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .init()), defaults: .default),
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(generate: .default), defaults: .init()) .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()),
] ]
} }
@@ -174,19 +186,23 @@ struct TestParseFiles: Sendable {
} }
var parsedFiles: [String] { var parsedFiles: [String] {
let runOptions = self.runOptions ?? PandocClient.RunOptions( let runOptions =
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), self.runOptions
projectDirectory: nil, ?? PandocClient.RunOptions(
quiet: true, loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
shouldBuild: false projectDirectory: nil,
) quiet: true,
shouldBuild: false
)
return runOptions.parseFiles(configuration.generate, defaults) return runOptions.parseFiles(configuration.generate, defaults)
} }
static let testCases: [Self] = [ static let testCases: [Self] = [
.init(expectedFiles: ["Report.md", "Definitions.md"]), .init(expectedFiles: ["Report.md", "Definitions.md"]),
.init(expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default), defaults: .init()), .init(
expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default),
defaults: .init()),
.init(expectedFiles: [], defaults: .init()), .init(expectedFiles: [], defaults: .init()),
.init( .init(
expectedFiles: ["custom.md"], expectedFiles: ["custom.md"],
@@ -199,7 +215,7 @@ struct TestParseFiles: Sendable {
quiet: true, quiet: true,
shouldBuild: false shouldBuild: false
) )
) ),
] ]
} }
@@ -223,16 +239,20 @@ struct TestParseIncludeInHeaderFiles: Sendable {
} }
var parsedFiles: [String] { var parsedFiles: [String] {
let runOptions = self.runOptions ?? PandocClient.RunOptions( let runOptions =
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) self.runOptions
) ?? PandocClient.RunOptions(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
)
return runOptions.parseIncludeInHeader(configuration.generate, defaults) return runOptions.parseIncludeInHeader(configuration.generate, defaults)
} }
static let testCases: [Self] = [ static let testCases: [Self] = [
.init(expectedHeaderFiles: ["head.tex", "footer.tex"]), .init(expectedHeaderFiles: ["head.tex", "footer.tex"]),
.init(expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default), defaults: .init()), .init(
expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default),
defaults: .init()),
.init(expectedHeaderFiles: [], defaults: .init()), .init(expectedHeaderFiles: [], defaults: .init()),
.init( .init(
expectedHeaderFiles: ["custom.tex"], expectedHeaderFiles: ["custom.tex"],
@@ -242,7 +262,7 @@ struct TestParseIncludeInHeaderFiles: Sendable {
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
includeInHeader: ["custom.tex"] includeInHeader: ["custom.tex"]
) )
) ),
] ]
} }
@@ -266,9 +286,11 @@ struct TestParseOutputFileName: Sendable {
} }
var parsedFileName: String { var parsedFileName: String {
let runOptions = self.runOptions ?? PandocClient.RunOptions( let runOptions =
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) self.runOptions
) ?? PandocClient.RunOptions(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
)
return runOptions.parseOutputFileName(configuration.generate, defaults) return runOptions.parseOutputFileName(configuration.generate, defaults)
} }
@@ -285,7 +307,7 @@ struct TestParseOutputFileName: Sendable {
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
outputFileName: "custom" outputFileName: "custom"
) )
) ),
] ]
} }
@@ -309,9 +331,11 @@ struct TestParseBuildDirectory: Sendable {
} }
var parsedBuildDirectory: String { var parsedBuildDirectory: String {
let runOptions = self.runOptions ?? PandocClient.RunOptions( let runOptions =
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) self.runOptions
) ?? PandocClient.RunOptions(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
)
return runOptions.parseBuildDirectory(configuration.generate, defaults) return runOptions.parseBuildDirectory(configuration.generate, defaults)
} }
@@ -328,6 +352,6 @@ struct TestParseBuildDirectory: Sendable {
buildDirectory: "custom", buildDirectory: "custom",
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
) )
) ),
] ]
} }

View File

@@ -145,6 +145,7 @@ struct PlaybookClientTests: TestCase {
@Test @Test
func generateTemplate() async throws { func generateTemplate() async throws {
try await withCapturingCommandClient("generateTemplate") { try await withCapturingCommandClient("generateTemplate") {
$0.fileClient.isDirectory = { _ in true }
$0.configurationClient = .mock() $0.configurationClient = .mock()
$0.playbookClient = .liveValue $0.playbookClient = .liveValue
} run: { } run: {
@@ -180,6 +181,7 @@ struct PlaybookClientTests: TestCase {
operation: @Sendable @escaping () async throws -> Void operation: @Sendable @escaping () async throws -> Void
) async rethrows { ) async rethrows {
try await withDependencies { try await withDependencies {
$0.fileClient.isDirectory = { _ in true }
$0.configurationClient = .mock(configuration) $0.configurationClient = .mock(configuration)
$0.commandClient = .capturing(capturing) $0.commandClient = .capturing(capturing)
$0.playbookClient = .liveValue $0.playbookClient = .liveValue

View File

@@ -2,23 +2,76 @@
# Build the executable # Build the executable
ARG SWIFT_IMAGE_VERSION="6.0.3" ARG SWIFT_IMAGE_VERSION="6.0.3"
FROM swift:${SWIFT_IMAGE_VERSION} AS build # ============================================================
# Build Swift Image
# ============================================================
FROM docker.io/swift:${SWIFT_IMAGE_VERSION} AS build
# Install OS updates
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && \
apt-get -q update && \
apt-get -q dist-upgrade -y && \
apt-get install -y libjemalloc-dev
WORKDIR /build WORKDIR /build
# Resolve dependencies, this creates a cached layer.
COPY ./Package.* ./ COPY ./Package.* ./
RUN swift package resolve RUN --mount=type=cache,target=/build/.build swift package resolve
COPY . . COPY . .
RUN swift build -c release -Xswiftc -g
# Run image # Build the application.
FROM swift:${SWIFT_IMAGE_VERSION}-slim RUN --mount=type=cache,target=/build/.build \
swift build -c release \
--product hpa \
--static-swift-stdlib \
-Xlinker -ljemalloc
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && apt-get -q update && \ # Switch to staging area.
WORKDIR /staging
# Copy main executable to staging area.
RUN --mount=type=cache,target=/build/.build \
cp "$(swift build --package-path /build -c release --show-bin-path)/hpa" ./
# ============================================================
# Run Image
# ============================================================
FROM docker.io/ubuntu:noble
# Update base image and install needed packages.
#
# NOTE: NB: Installs vim as minimal text editor to use inside the container, bc
# when I mount my home directory / use my neovim config it requires
# neovim v11+, but generally only going to edit ansible vault files
# inside the container.
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && \
apt-get -q update && \
apt-get -q dist-upgrade -y && \
apt-get -q install -y \ apt-get -q install -y \
ansible \ ansible \
curl \ curl \
imagemagick \
pandoc \ pandoc \
texlive \ texlive \
texlive-xetex \
libjemalloc2 \
libcurl4 \
tzdata \
vim \
&& rm -r /var/lib/apt/lists/* && rm -r /var/lib/apt/lists/*
COPY --from=build /build/.build/release/hpa /usr/local/bin # Install the hpa executable.
CMD ["/bin/bash", "-xc", "/usr/local/bin/hpa"] COPY --from=build /staging/hpa /usr/local/bin
# Install the entrypoint script and make execuatable.
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir /root/project
# Set workdir and volume mounts.
WORKDIR /root/project
VOLUME /root/project
ENTRYPOINT [ "/entrypoint.sh" ]
CMD ["--help"]

27
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
declare -a args
# Allows to attach to a shell inside the container, or run ansbile commands,
# otherwise run the 'hpa' script with the given arguments.
#
while [[ $# -gt 0 ]]; do
if [[ $1 == "/bin/bash" ]] || [[ $1 == "bash" ]]; then
shift
/bin/bash "$@"
exit $?
elif [[ $1 == "/bin/sh" ]] || [[ $1 == "sh" ]]; then
shift
/bin/sh "$@"
exit $?
elif [[ $1 =~ ^ansible ]]; then
exec "$@"
exit $?
else
args+=("$1")
fi
shift
done
# If we made it here then run the hpa script.
/usr/local/bin/hpa "${args[@]}"

View File

@@ -52,6 +52,8 @@ test-docker *ARGS: (build-docker-test)
{{docker_image_name}}:test \ {{docker_image_name}}:test \
swift test {{ARGS}} swift test {{ARGS}}
alias td := test-docker
# Remove bottles # Remove bottles
remove-bottles: remove-bottles:
rm -rf *.gz rm -rf *.gz