Template support for input parameters (#4)

This PR contains the work done to support input parameters for the `create` command of the executable target, and to render content dynamically for the newly-generated project.

Reviewed-on: #4
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
Javier Cicchelli 2025-02-17 22:11:05 +00:00 committed by Javier Cicchelli
parent 9be8fa4a31
commit 212ca52279
33 changed files with 812 additions and 202 deletions

View File

@ -19,19 +19,23 @@ extension Colibri {
mutating func run() async throws {
let fileService = FileService()
let templateService = try await TemplateService(templateFolder: "Files/Templates")
let terminalService = TerminalService()
let copyFiles = CopyFilesTask(fileService: fileService)
let createFolders = CreateFoldersTask(fileService: fileService)
let createRootFolder = CreateRootFolderTask(fileService: fileService)
let initGitInFolder = InitGitInFolderTask()
let initGitInFolder = InitGitInFolderTask(terminalService: terminalService)
let renderFiles = RenderFilesTask(fileService: fileService,
templateService: templateService)
let rootFolder = try await createRootFolder(
name: options.name,
at: options.locationURL
)
let rootFolder = try await createRootFolder(name: options.name,
at: options.locationURL)
try await createFolders(at: rootFolder)
try await copyFiles(to: rootFolder)
try await renderFiles(at: rootFolder,
with: Project(name: options.name))
try await initGitInFolder(at: rootFolder)
}

View File

@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Adam Fowler
Copyright 2025 Röck+Cöde
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@ -11,7 +11,7 @@ struct App: AsyncParsableCommand {
// MARK: Functions
mutating func run() async throws {
let builder = AppBuilder(name: "App")
let builder = AppBuilder(name: "{{ name }}")
let app = try await builder(options)
try await app.runService()

View File

@ -3,7 +3,7 @@
import PackageDescription
let package = Package(
name: "App",
name: "{{ name }}",
platforms: [
.macOS(.v14)
],

View File

@ -8,7 +8,7 @@ struct AppTests {
// MARK: Properties
private let arguments = TestArguments()
private let builder = AppBuilder(name: "App")
private let builder = AppBuilder(name: "{{ name }}")
// MARK: Route tests

View File

@ -1,16 +1,13 @@
enum File: String {
case app = "App"
case appArguments = "AppArguments"
case appBuilder = "AppBuilder"
case appOptions = "AppOptions"
case appTests = "AppTests"
case dockerFile = "DockerFile"
case dockerIgnore = "DockerIgnore"
case environment = "Environment"
case gitIgnore = "GitIgnore"
case license = "License"
case loggerLevel = "LoggerLevel"
case package = "Package"
case readme = "Readme"
case testArguments = "TestArguments"
@ -24,11 +21,9 @@ extension File {
var fileName: String {
switch self {
case .app: "App.swift"
case .appArguments: "AppArguments.swift"
case .appBuilder: "AppBuilder.swift"
case .appOptions: "AppOptions.swift"
case .appTests: "AppTests.swift"
case .dockerFile: "Dockerfile"
case .dockerIgnore: ".dockerignore"
case .environment: "Environment+Properties.swift"
@ -36,7 +31,6 @@ extension File {
case .license: "LICENSE"
case .loggerLevel: "LoggerLevel+Conformances.swift"
case .readme: "README.md"
case .package: "Package.swift"
case .testArguments: "TestArguments.swift"
}
}
@ -47,9 +41,8 @@ extension File {
var folder: Folder {
switch self {
case .app, .appOptions: .app
case .appOptions: .app
case .appArguments, .appBuilder: .libraryPublic
case .appTests: .testCasesPublic
case .environment, .loggerLevel: .libraryInternal
case .testArguments: .testHelpers
default: .root
@ -60,9 +53,9 @@ extension File {
let basePath = "Resources/Files/Sources"
return switch self {
case .app, .appOptions: "\(basePath)/App"
case .appOptions: "\(basePath)/App"
case .appArguments, .appBuilder, .environment, .loggerLevel: "\(basePath)/Library"
case .appTests, .testArguments: "\(basePath)/Test"
case .testArguments: "\(basePath)/Test"
default: basePath
}
}

View File

@ -0,0 +1,37 @@
enum Template: String {
case app = "App/App"
case appTests = "Test/AppTests"
case package = "Package"
}
// MARK: - Properties
extension Template {
// MARK: Computed
var fileName: String {
switch self {
case .app: "App.swift"
case .appTests: "AppTests.swift"
case .package: "Package.swift"
}
}
var filePath: String {
folder.path + fileName
}
var folder: Folder {
switch self {
case .app: .app
case .appTests: .testCasesPublic
default: .root
}
}
}
// MARK: - CaseIterable
extension Template: CaseIterable {}

View File

@ -1,75 +0,0 @@
import Foundation
struct RunProcessTask {
// MARK: Type aliases
typealias Output = String
// MARK: Properties
private var process: Processable
// MARK: Initialisers
init(process: Processable) {
self.process = process
}
// MARK: Functions
@discardableResult
mutating func callAsFunction(
path: String, arguments: [String] = []
) async throws (RunProcessError) -> Output {
process.executableURL = URL(at: path)
process.arguments = arguments
let pipeError = Pipe()
let pipeOutput = Pipe()
process.standardError = pipeError
process.standardOutput = pipeOutput
async let streamOutput = pipeOutput.availableData.append()
async let streamError = pipeError.availableData.append()
do {
try process.run()
let dataOutput = await streamOutput
let dataError = await streamError
guard dataError.isEmpty else {
guard let errorOutput = String(data: dataError, encoding: .utf8) else {
throw RunProcessError.unexpected
}
throw RunProcessError.output(errorOutput)
}
guard let output = String(data: dataOutput, encoding: .utf8) else {
throw RunProcessError.unexpected
}
return await withCheckedContinuation { continuation in
process.terminationHandler = { _ in
continuation.resume(returning: output)
}
}
} catch let error as RunProcessError {
throw error
} catch {
throw RunProcessError.captured(error.localizedDescription)
}
}
}
// MARK: - Errors
public enum RunProcessError: Error, Equatable {
case captured(_ output: String)
case output(_ output: String)
case unexpected
}

View File

@ -0,0 +1,13 @@
public struct Project: Equatable, Sendable {
// MARK: Properties
let name: String
// MARK: Initialisers
public init(name: String) {
self.name = name
}
}

View File

@ -1,6 +1,10 @@
import Foundation
public protocol Bundleable {
// MARK: Computed
var resourcePath: String? { get }
// MARK: Functions

View File

@ -9,6 +9,7 @@ public protocol FileServicing {
// MARK: Functions
func copyFile(from source: URL, to destination: URL) async throws (FileServiceError)
func createFile(at location: URL, with data: Data) async throws (FileServiceError)
func createFolder(at location: URL) async throws (FileServiceError)
func deleteItem(at location: URL) async throws (FileServiceError)
func isItemExists(at location: URL) async throws (FileServiceError) -> Bool
@ -18,6 +19,8 @@ public protocol FileServicing {
// MARK: - Errors
public enum FileServiceError: Error, Equatable {
case fileDataIsEmpty
case fileNotCreated
case folderNotCreated
case itemAlreadyExists
case itemEmptyData

View File

@ -0,0 +1,16 @@
public protocol TemplateServicing {
// MARK: Functions
func render(_ object: Any, on template: String) async throws (TemplateServiceError) -> String
}
// MARK: - Errors
public enum TemplateServiceError: Error {
case contentNotRendered
case resourcePathNotFound
case serviceNotInitialized
case templateNotFound
}

View File

@ -0,0 +1,19 @@
import Foundation
public protocol TerminalServicing {
// MARK: Functions
@discardableResult
func run(_ executableURL: URL, arguments: [String]) async throws (TerminalServiceError) -> String
}
// MARK: - Errors
public enum TerminalServiceError: Error, Equatable {
case captured(_ output: String)
case output(_ output: String)
case unexpected
}

View File

@ -1,7 +1,7 @@
import Foundation
public struct FileService: FileServicing {
public struct FileService {
// MARK: Properties
private let fileManager: FileManager
@ -12,6 +12,12 @@ public struct FileService: FileServicing {
self.fileManager = fileManager
}
}
// MARK: - FileServicing
extension FileService: FileServicing {
// MARK: Computed
public var currentFolder: URL {
@ -24,7 +30,7 @@ public struct FileService: FileServicing {
public func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) {
guard try await !isItemExists(at: destination) else {
throw FileServiceError.itemAlreadyExists
throw .itemAlreadyExists
}
var itemData: Data?
@ -32,43 +38,59 @@ public struct FileService: FileServicing {
do {
itemData = try Data(contentsOf: source)
} catch {
throw FileServiceError.itemEmptyData
throw .itemEmptyData
}
do {
try itemData?.write(to: destination, options: .atomic)
} catch {
throw FileServiceError.itemNotCopied
throw .itemNotCopied
}
}
public func createFile(at location: URL, with data: Data) async throws (FileServiceError) {
guard try await !isItemExists(at: location) else {
throw .itemAlreadyExists
}
guard !data.isEmpty else {
throw .fileDataIsEmpty
}
do {
try data.write(to: location, options: .atomic)
} catch {
throw .fileNotCreated
}
}
public func createFolder(at location: URL) async throws (FileServiceError) {
guard try await !isItemExists(at: location) else {
throw FileServiceError.itemAlreadyExists
throw .itemAlreadyExists
}
do {
try fileManager.createDirectory(at: location, withIntermediateDirectories: true)
} catch {
throw FileServiceError.folderNotCreated
throw .folderNotCreated
}
}
public func deleteItem(at location: URL) async throws (FileServiceError) {
guard try await isItemExists(at: location) else {
throw FileServiceError.itemNotExists
throw .itemNotExists
}
do {
try fileManager.removeItem(at: location)
} catch {
throw FileServiceError.itemNotDeleted
throw .itemNotDeleted
}
}
public func isItemExists(at location: URL) async throws (FileServiceError) -> Bool {
guard location.isFileURL else {
throw FileServiceError.itemNotFileURL
throw .itemNotFileURL
}
let filePath = location.pathString

View File

@ -0,0 +1,50 @@
import Foundation
import Mustache
public struct TemplateService {
// MARK: Properties
private let mustacheRenderer: MustacheLibrary
// MARK: Initialisers
public init(
bundle: Bundleable? = nil,
templateFolder: String
) async throws (TemplateServiceError) {
guard let pathResources = (bundle ?? Bundle.module).resourcePath else {
throw .resourcePathNotFound
}
let pathTemplates = pathResources + "/" + templateFolder
do {
self.mustacheRenderer = try await MustacheLibrary(directory: pathTemplates)
} catch {
throw .serviceNotInitialized
}
}
}
// MARK: - TemplateServicing
extension TemplateService: TemplateServicing {
// MARK: Functions
public func render(_ object: Any, on template: String) async throws (TemplateServiceError) -> String {
guard mustacheRenderer.getTemplate(named: template) != nil else {
throw .templateNotFound
}
guard let content = mustacheRenderer.render(object, withTemplate: template) else {
throw .contentNotRendered
}
return content
}
}

View File

@ -0,0 +1,60 @@
import Foundation
public struct TerminalService {
// MARK: Initialisers
public init() {}
}
// MARK: - TerminalServicing
extension TerminalService: TerminalServicing {
// MARK: Functions
public func run(_ executableURL: URL, arguments: [String]) async throws (TerminalServiceError) -> String {
let process = Process()
let standardError = Pipe()
let standardOutput = Pipe()
process.executableURL = executableURL
process.arguments = arguments
process.standardError = standardError
process.standardOutput = standardOutput
async let streamOutput = standardOutput.availableData.append()
async let streamError = standardError.availableData.append()
do {
try process.run()
let dataOutput = await streamOutput
let dataError = await streamError
guard dataError.isEmpty else {
guard let errorOutput = String(data: dataError, encoding: .utf8) else {
throw TerminalServiceError.unexpected
}
throw TerminalServiceError.output(errorOutput)
}
guard let output = String(data: dataOutput, encoding: .utf8) else {
throw TerminalServiceError.unexpected
}
return await withCheckedContinuation { continuation in
process.terminationHandler = { _ in
continuation.resume(returning: output)
}
}
} catch let error as TerminalServiceError {
throw error
} catch {
throw .captured(error.localizedDescription)
}
}
}

View File

@ -1,24 +1,26 @@
import Foundation
public struct InitGitInFolderTask {
// MARK: Properties
private let terminalService: TerminalServicing
// MARK: Initialisers
public init() {}
public init(terminalService: TerminalServicing) {
self.terminalService = terminalService
}
// MARK: Functions
public func callAsFunction(at rootFolder: URL) async throws (RunProcessError) {
let pathCommand = "/usr/bin/git"
public func callAsFunction(at rootFolder: URL) async throws (TerminalServiceError) {
let executableURL = URL(at: "/usr/bin/git")
let pathFolder = rootFolder.pathString
var gitInit = RunProcessTask(process: Process())
var gitAdd = RunProcessTask(process: Process())
var gitCommit = RunProcessTask(process: Process())
try await gitInit(path: pathCommand, arguments: ["init", pathFolder])
try await gitAdd(path: pathCommand, arguments: ["-C", pathFolder, "add", "."])
try await gitCommit(path: pathCommand, arguments: ["-C", pathFolder, "commit", "-m", "Initial commit"])
try await terminalService.run(executableURL, arguments: ["init", pathFolder])
try await terminalService.run(executableURL, arguments: ["-C", pathFolder, "add", "."])
try await terminalService.run(executableURL, arguments: ["-C", pathFolder, "commit", "-m", "Initial commit"])
}
}

View File

@ -0,0 +1,34 @@
import Foundation
public struct RenderFilesTask {
// MARK: Computed
private let fileService: FileServicing
private let templateService: TemplateServicing
// MARK: Initialisers
public init(
fileService: FileServicing,
templateService: TemplateServicing
) {
self.fileService = fileService
self.templateService = templateService
}
// MARK: Functions
public func callAsFunction(
at rootFolder: URL,
with model: Project
) async throws {
for template in Template.allCases {
let content = try await templateService.render(model, on: template.rawValue)
let fileURL = rootFolder.appendingPath(template.filePath)
try await fileService.createFile(at: fileURL, with: Data(content.utf8))
}
}
}

View File

@ -12,7 +12,8 @@ let package = Package(
.library(name: "ColibriLibrary", targets: ["ColibriLibrary"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0")
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
.package(url: "https://github.com/hummingbird-project/swift-mustache", from: "2.0.0")
],
targets: [
.executableTarget(
@ -25,7 +26,9 @@ let package = Package(
),
.target(
name: "ColibriLibrary",
dependencies: [],
dependencies: [
.product(name: "Mustache", package: "swift-mustache")
],
path: "Library",
resources: [
.copy("Resources")

View File

@ -53,45 +53,37 @@ struct FileTests {
private extension FileTests {
enum Expectation {
static let fileNames: [String] = [
"App.swift",
"AppArguments.swift",
"AppBuilder.swift",
"AppOptions.swift",
"AppTests.swift",
"Dockerfile",
".dockerignore",
"Environment+Properties.swift",
".gitignore",
"LICENSE",
"LoggerLevel+Conformances.swift",
"Package.swift",
"README.md",
"TestArguments.swift"
]
static let filePaths: [String] = [
"App/Sources/App.swift",
"Library/Sources/Public/AppArguments.swift",
"Library/Sources/Public/AppBuilder.swift",
"App/Sources/AppOptions.swift",
"Test/Sources/Cases/Public/AppTests.swift",
"Dockerfile",
".dockerignore",
"Library/Sources/Internal/Environment+Properties.swift",
".gitignore",
"LICENSE",
"Library/Sources/Internal/LoggerLevel+Conformances.swift",
"Package.swift",
"README.md",
"Test/Sources/Helpers/TestArguments.swift"
]
static let folders: [Folder] = [
.app,
.libraryPublic,
.libraryPublic,
.app,
.testCasesPublic,
.root,
.root,
.libraryInternal,
@ -99,17 +91,13 @@ private extension FileTests {
.root,
.libraryInternal,
.root,
.root,
.testHelpers
]
static let resourcePaths: [String] = [
"Resources/Files/Sources/App",
"Resources/Files/Sources/Library",
"Resources/Files/Sources/Library",
"Resources/Files/Sources/App",
"Resources/Files/Sources/Test",
"Resources/Files/Sources",
"Resources/Files/Sources",
"Resources/Files/Sources/Library",
@ -117,9 +105,7 @@ private extension FileTests {
"Resources/Files/Sources",
"Resources/Files/Sources/Library",
"Resources/Files/Sources",
"Resources/Files/Sources",
"Resources/Files/Sources/Test"
]
}
}

View File

@ -0,0 +1,63 @@
import Testing
@testable import ColibriLibrary
struct TemplateTests {
// MARK: Properties tests
@Test(arguments: zip(Template.allCases, Expectation.fileNames))
func fileName(for template: Template, expects fileName: String) async throws {
// GIVEN
// WHEN
let result = template.fileName
// THEN
#expect(result == fileName)
}
@Test(arguments: zip(Template.allCases, Expectation.filePaths))
func filePath(for template: Template, expects filePath: String) async throws {
// GIVEN
// WHEN
let result = template.filePath
// THEN
#expect(result == filePath)
}
@Test(arguments: zip(Template.allCases, Expectation.folders))
func folder(for template: Template, expects folder: Folder) async throws {
// GIVEN
// WHEN
let result = template.folder
// THEN
#expect(result == folder)
}
}
// MARK: - Expectations
private extension TemplateTests {
enum Expectation {
static let fileNames: [String] = [
"App.swift",
"AppTests.swift",
"Package.swift",
]
static let filePaths: [String] = [
"App/Sources/App.swift",
"Test/Sources/Cases/Public/AppTests.swift",
"Package.swift",
]
static let folders: [Folder] = [
.app,
.testCasesPublic,
.root,
]
}
}

View File

@ -1,68 +0,0 @@
import Foundation
import Testing
@testable import ColibriLibrary
struct RunProcessTaskTests {
// MARK: Properties
private var process: Process
// MARK: Initialisers
init() {
self.process = Process()
}
// MARK: Functions tests
@Test(arguments: [Argument.empty, Argument.listAllInFolder])
func run(with arguments: [String]) async throws {
// GIVEN
var task = RunProcessTask(process: process)
// WHEN
let output = try await task(path: .ls, arguments: arguments)
// THEN
#expect(output.isEmpty == false)
}
@Test(arguments: zip([Argument.help, Argument.listAllInPWD], Throw.outputs))
func runThrows(with arguments: [String], throws error: RunProcessError) async throws {
// GIVEN
var task = RunProcessTask(process: process)
// WHEN
// THEN
await #expect(throws: error) {
try await task(path: .ls, arguments: arguments)
}
}
}
// MARK: - String+Constants
private extension String {
static let ls = "/bin/ls"
}
// MARK: - Parameters
private extension RunProcessTaskTests {
enum Argument {
static let empty: [String] = []
static let help: [String] = ["--help"]
static let listAllInFolder: [String] = ["-la", "."]
static let listAllInPWD: [String] = ["-la", "~"]
}
enum Throw {
static let outputs: [RunProcessError] = [
.output("ls: unrecognized option `--help\'\nusage: ls [-@ABCFGHILOPRSTUWXabcdefghiklmnopqrstuvwxy1%,] [--color=when] [-D format] [file ...]\n"),
.output("ls: ~: No such file or directory\n")
]
}
}

View File

@ -35,7 +35,7 @@ struct FileServiceTests {
// WHEN
try await service.copyFile(from: source, to: destination)
// THENn
// THEN
#expect(spy.actions.count == 1)
let action = try #require(spy.actions.last)
@ -57,6 +57,37 @@ struct FileServiceTests {
#expect(spy.actions.isEmpty == true)
}
@Test(arguments: zip([URL.someNewFile],
[Data("some data goes here...".utf8)]))
func createFile(with location: URL, and data: Data) async throws {
// GIVEN
let service = service(action: .createFile(location, data))
// WHEN
try await service.createFile(at: location, with: data)
// THEN
#expect(spy.actions.count == 1)
let action = try #require(spy.actions.last)
#expect(action == .fileCreated(location, data))
}
@Test(arguments: [FileServiceError.itemAlreadyExists, .fileDataIsEmpty, .fileNotCreated])
func createFile(throws error: FileServiceError) async throws {
// GIVEN
let service = service(action: .error(error))
// WHEN
// THEN
await #expect(throws: error) {
try await service.createFile(at: .someNewFile, with: .init())
}
#expect(spy.actions.isEmpty == true)
}
@Test(arguments: [URL.someNewFolder, .someNewFile])
func createFolder(with location: URL) async throws {
// GIVEN

View File

@ -0,0 +1,48 @@
import ColibriLibrary
import Foundation
import Testing
struct TemplateServiceTests {
// MARK: Properties
private let spy = TemplateServiceSpy()
// MARK: Functions tests
@Test(arguments: [String.content])
func render(_ content: String) async throws {
// GIVEN
let service = TemplateServiceMock(action: .render(content), spy: spy)
// WHEN
let result = try await service.render([:], on: .template)
// THEN
#expect(result == content)
#expect(spy.actions.isEmpty == false)
}
@Test(arguments: [TemplateServiceError.serviceNotInitialized, .resourcePathNotFound, .templateNotFound, .contentNotRendered])
func render(throws error: TemplateServiceError) async throws {
// GIVEN
let service = TemplateServiceMock(action: .error(error), spy: spy)
// WHEN
// THEN
await #expect(throws: error) {
try await service.render([:], on: .template)
}
#expect(spy.actions.isEmpty == true)
}
}
// MARK: - String+Constants
private extension String {
static let content = ""
static let template = ""
}

View File

@ -0,0 +1,51 @@
import ColibriLibrary
import Foundation
import Testing
struct TerminalServiceTests {
// MARK: Properties
private let spy = TerminalServiceSpy()
// MARK: Functions
@Test(arguments: [URL.someNewFile], [[], ["--example"], ["--example", "--more", "--etc"]])
func run(with executableURL: URL, and arguments: [String]) async throws {
// GIVEN
let service = TerminalServiceMock(action: .run(executableURL, arguments), spy: spy)
// WHEN
let result = try await service.run(executableURL, arguments: arguments)
// THEN
#expect(result == .content)
#expect(spy.actions.isEmpty == false)
let action = try #require(spy.actions.last)
#expect(action == .ran(executableURL, arguments))
}
@Test(arguments: [TerminalServiceError.unexpected, .captured("Some captured error"), .output("Some output error")])
func run(throws error: TerminalServiceError) async throws {
// GIVEN
let service = TerminalServiceMock(action: .error(error), spy: spy)
// WHEN
// THEN
await #expect(throws: error) {
try await service.run(URL.someNewFile, arguments: [])
}
#expect(spy.actions.isEmpty == true)
}
}
// MARK: - String+Constants
private extension String {
static let content = ""
}

View File

@ -0,0 +1,30 @@
import Foundation
import Testing
@testable import ColibriLibrary
struct InitGitInFolderTaskTests {
// MARK: Functions tests
@Test(arguments: [URL.someCurrentFolder, .someNewFolder, .someDotFolder, .someTildeFolder])
func task(at rootFolder: URL) async throws {
// GIVEN
let terminalService = TerminalServiceSpy()
let initGitInFolder = InitGitInFolderTask(terminalService: terminalService)
// WHEN
try await initGitInFolder(at: rootFolder)
// THEN
let executableURL = URL(at: "/usr/bin/git")
let pathFolder = rootFolder.pathString
#expect(terminalService.actions.count == 3)
#expect(terminalService.actions[0] == .ran(executableURL, ["init", pathFolder]))
#expect(terminalService.actions[1] == .ran(executableURL, ["-C", pathFolder, "add", "."]))
#expect(terminalService.actions[2] == .ran(executableURL, ["-C", pathFolder, "commit", "-m", "Initial commit"]))
}
}

View File

@ -0,0 +1,41 @@
import Foundation
import Testing
@testable import ColibriLibrary
struct RenderFilesTaskTests {
@Test(arguments: [URL.someCurrentFolder], [Project(name: "Some name goes here...")])
func task(at rootFolder: URL, with project: Project) async throws {
// GIVEN
let fileService = FileServiceSpy()
let templateService = TemplateServiceSpy()
let renderFiles = RenderFilesTask(fileService: fileService,
templateService: templateService)
// WHEN
try await renderFiles(at: rootFolder, with: project)
// THEN
let fileData = Data()
let templates = Template.allCases
#expect(fileService.actions.count == 3)
#expect(templateService.actions.count == 3)
fileService.actions.enumerated().forEach { index, action in
#expect(action == .fileCreated(rootFolder.appendingPath(templates[index].filePath), fileData))
}
templateService.actions.enumerated().forEach { index, action in
if case let .rendered(object, template) = action {
#expect(object as? Project == project)
#expect(template == templates[index].rawValue)
} else {
Issue.record("Action should have been a case of the `TemplateServiceSpy.Action` enumeration.")
}
}
}
}

View File

@ -64,6 +64,19 @@ extension FileServiceMock: FileServicing {
}
}
func createFile(at location: URL, with data: Data) async throws (FileServiceError) {
guard let nextAction else { return }
switch nextAction {
case .error(let error):
throw error
case let .createFile(location, data):
try await spy?.createFile(at: location, with: data)
default:
break
}
}
func createFolder(at location: URL) async throws (FileServiceError) {
guard let nextAction else { return }
@ -127,6 +140,7 @@ private extension FileServiceMock {
extension FileServiceMock {
enum Action {
case copyFile(URL, URL)
case createFile(URL, Data)
case createFolder(URL)
case deleteItem(URL)
case error(FileServiceError)

View File

@ -0,0 +1,75 @@
import ColibriLibrary
import Foundation
final class TemplateServiceMock {
// MARK: Properties
private var actions: [Action] = []
private weak var spy: TemplateServiceSpy?
// MARK: Initialisers
init(
action: Action,
spy: TemplateServiceSpy? = nil
) {
self.actions.append(action)
self.spy = spy
}
}
// MARK: - TemplateServicing
extension TemplateServiceMock: TemplateServicing {
// MARK: Functions
@discardableResult
func render(_ object: Any, on template: String) async throws(TemplateServiceError) -> String {
guard let nextAction else { return .empty }
switch nextAction {
case .error(let error):
throw error
case .render(let content):
try await spy?.render(object, on: template)
return content
}
}
}
// MARK: - Helpers
private extension TemplateServiceMock {
// MARK: Computed
var nextAction: Action? {
guard !actions.isEmpty else {
return nil
}
return actions.removeFirst()
}
}
// MARK: - Actions
extension TemplateServiceMock {
enum Action {
case error(TemplateServiceError)
case render(String)
}
}
// MARK: - String+Constants
private extension String {
static let empty = ""
}

View File

@ -0,0 +1,73 @@
import ColibriLibrary
import Foundation
final class TerminalServiceMock {
// MARK: Properties
private var actions: [Action] = []
private weak var spy: TerminalServiceSpy?
// MARK: Initialisers
init(
action: Action,
spy: TerminalServiceSpy? = nil
) {
self.actions.append(action)
self.spy = spy
}
}
// MARK: - TerminalServicing
extension TerminalServiceMock: TerminalServicing {
// MARK: Functions
func run(_ executableURL: URL, arguments: [String]) async throws (TerminalServiceError) -> String {
guard let nextAction else { return .empty }
switch nextAction {
case .error(let error):
throw error
case let .run(executableURL, arguments):
try await spy?.run(executableURL, arguments: arguments)
return .empty
}
}
}
// MARK: - Helpers
private extension TerminalServiceMock {
// MARK: Computed
var nextAction: Action? {
guard !actions.isEmpty else {
return nil
}
return actions.removeFirst()
}
}
// MARK: - Actions
extension TerminalServiceMock {
enum Action {
case error(TerminalServiceError)
case run(URL, [String])
}
}
// MARK: - String+Constants
private extension String {
static let empty = ""
}

View File

@ -1,7 +1,6 @@
import ColibriLibrary
import Foundation
@testable import ColibriLibrary
final class FileServiceSpy {
// MARK: Properties
@ -22,6 +21,10 @@ extension FileServiceSpy: FileServicing {
actions.append(.fileCopied(source, destination))
}
func createFile(at location: URL, with data: Data) async throws (FileServiceError) {
actions.append(.fileCreated(location, data))
}
func createFolder(at location: URL) async throws (FileServiceError) {
actions.append(.folderCreated(location))
}
@ -43,6 +46,7 @@ extension FileServiceSpy: FileServicing {
extension FileServiceSpy {
enum Action: Equatable {
case fileCreated(_ location: URL, _ data: Data)
case fileCopied(_ source: URL, _ destination: URL)
case folderCreated(_ location: URL)
case itemDeleted(_ location: URL)

View File

@ -0,0 +1,38 @@
import ColibriLibrary
final class TemplateServiceSpy {
// MARK: Properties
private(set) var actions: [Action] = []
}
// MARK: - TemplateServicing
extension TemplateServiceSpy: TemplateServicing {
// MARK: Functions
@discardableResult
func render(_ object: Any, on template: String) async throws (TemplateServiceError) -> String {
actions.append(.rendered(object, template))
return .content
}
}
// MARK: - Actions
extension TemplateServiceSpy {
enum Action {
case rendered(_ object: Any, _ template: String)
}
}
// MARK: - String+Constants
private extension String {
static let content = ""
}

View File

@ -0,0 +1,39 @@
import ColibriLibrary
import Foundation
final class TerminalServiceSpy {
// MARK: Properties
private(set) var actions: [Action] = []
}
// MARK: - TerminalServicing
extension TerminalServiceSpy: TerminalServicing {
// MARK: Functions
@discardableResult
func run(_ executableURL: URL, arguments: [String]) async throws(TerminalServiceError) -> String {
actions.append(.ran(executableURL, arguments))
return .content
}
}
// MARK: - Actions
extension TerminalServiceSpy {
enum Action: Equatable {
case ran(_ executableURL: URL, _ arguments: [String])
}
}
// MARK: - String+Constants
private extension String {
static let content = ""
}