From 212ca52279c051c1e2ec0611a920aa2b152d1b68 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 17 Feb 2025 22:11:05 +0000 Subject: [PATCH] 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: https://repo.rock-n-code.com/rock-n-code/colibri/pulls/4 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Sources/Commands/CreateCommand.swift | 16 ++-- Library/Resources/Files/Sources/License | 2 +- .../App/App => Templates/App/App.mustache} | 2 +- .../Package => Templates/Package.mustache} | 2 +- .../Test/AppTests.mustache} | 2 +- .../Sources/Internal/Enumerations/File.swift | 13 +--- .../Internal/Enumerations/Template.swift | 37 +++++++++ .../Internal/Tasks/RunProcessTask.swift | 75 ------------------- Library/Sources/Public/Models/Project.swift | 13 ++++ .../Sources/Public/Protocols/Bundleable.swift | 4 + .../Public/Protocols/FileServicing.swift | 3 + .../Public/Protocols/TemplateServicing.swift | 16 ++++ .../Public/Protocols/TerminalServicing.swift | 19 +++++ .../Sources/Public/Services/FileService.swift | 42 ++++++++--- .../Public/Services/TemplateService.swift | 50 +++++++++++++ .../Public/Services/TerminalService.swift | 60 +++++++++++++++ .../Public/Tasks/InitGitInFolderTask.swift | 22 +++--- .../Public/Tasks/RenderFilesTask.swift | 34 +++++++++ Package.swift | 7 +- .../Internal/Enumerations/FileTests.swift | 14 ---- .../Internal/Enumerations/TemplateTests.swift | 63 ++++++++++++++++ .../Internal/Tasks/RunProcessTaskTests.swift | 68 ----------------- .../Public/Services/FileServiceTests.swift | 33 +++++++- .../Services/TemplateServiceTests.swift | 48 ++++++++++++ .../Services/TerminalServiceTests.swift | 51 +++++++++++++ .../Tasks/InitGitInFolderTaskTests.swift | 30 ++++++++ .../Public/Tasks/RenderFilesTaskTests.swift | 41 ++++++++++ .../Helpers/Mocks/FileServiceMock.swift | 14 ++++ .../Helpers/Mocks/TemplateServiceMock.swift | 75 +++++++++++++++++++ .../Helpers/Mocks/TerminalServiceMock.swift | 73 ++++++++++++++++++ .../Helpers/Spies/FileServiceSpy.swift | 8 +- .../Helpers/Spies/TemplateServiceSpy.swift | 38 ++++++++++ .../Helpers/Spies/TerminalServiceSpy.swift | 39 ++++++++++ 33 files changed, 812 insertions(+), 202 deletions(-) rename Library/Resources/Files/{Sources/App/App => Templates/App/App.mustache} (85%) rename Library/Resources/Files/{Sources/Package => Templates/Package.mustache} (98%) rename Library/Resources/Files/{Sources/Test/AppTests => Templates/Test/AppTests.mustache} (92%) create mode 100644 Library/Sources/Internal/Enumerations/Template.swift delete mode 100644 Library/Sources/Internal/Tasks/RunProcessTask.swift create mode 100644 Library/Sources/Public/Models/Project.swift create mode 100644 Library/Sources/Public/Protocols/TemplateServicing.swift create mode 100644 Library/Sources/Public/Protocols/TerminalServicing.swift create mode 100644 Library/Sources/Public/Services/TemplateService.swift create mode 100644 Library/Sources/Public/Services/TerminalService.swift create mode 100644 Library/Sources/Public/Tasks/RenderFilesTask.swift create mode 100644 Test/Sources/Cases/Internal/Enumerations/TemplateTests.swift delete mode 100644 Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift create mode 100644 Test/Sources/Cases/Public/Services/TemplateServiceTests.swift create mode 100644 Test/Sources/Cases/Public/Services/TerminalServiceTests.swift create mode 100644 Test/Sources/Cases/Public/Tasks/InitGitInFolderTaskTests.swift create mode 100644 Test/Sources/Cases/Public/Tasks/RenderFilesTaskTests.swift create mode 100644 Test/Sources/Helpers/Mocks/TemplateServiceMock.swift create mode 100644 Test/Sources/Helpers/Mocks/TerminalServiceMock.swift create mode 100644 Test/Sources/Helpers/Spies/TemplateServiceSpy.swift create mode 100644 Test/Sources/Helpers/Spies/TerminalServiceSpy.swift diff --git a/Executable/Sources/Commands/CreateCommand.swift b/Executable/Sources/Commands/CreateCommand.swift index 9f35af7..8bae799 100644 --- a/Executable/Sources/Commands/CreateCommand.swift +++ b/Executable/Sources/Commands/CreateCommand.swift @@ -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) } diff --git a/Library/Resources/Files/Sources/License b/Library/Resources/Files/Sources/License index bea052c..05b900f 100644 --- a/Library/Resources/Files/Sources/License +++ b/Library/Resources/Files/Sources/License @@ -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. diff --git a/Library/Resources/Files/Sources/App/App b/Library/Resources/Files/Templates/App/App.mustache similarity index 85% rename from Library/Resources/Files/Sources/App/App rename to Library/Resources/Files/Templates/App/App.mustache index 9183ae7..c33bcd7 100644 --- a/Library/Resources/Files/Sources/App/App +++ b/Library/Resources/Files/Templates/App/App.mustache @@ -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() diff --git a/Library/Resources/Files/Sources/Package b/Library/Resources/Files/Templates/Package.mustache similarity index 98% rename from Library/Resources/Files/Sources/Package rename to Library/Resources/Files/Templates/Package.mustache index 0c804dc..7d500db 100644 --- a/Library/Resources/Files/Sources/Package +++ b/Library/Resources/Files/Templates/Package.mustache @@ -3,7 +3,7 @@ import PackageDescription let package = Package( - name: "App", + name: "{{ name }}", platforms: [ .macOS(.v14) ], diff --git a/Library/Resources/Files/Sources/Test/AppTests b/Library/Resources/Files/Templates/Test/AppTests.mustache similarity index 92% rename from Library/Resources/Files/Sources/Test/AppTests rename to Library/Resources/Files/Templates/Test/AppTests.mustache index 31f1868..1cf3444 100644 --- a/Library/Resources/Files/Sources/Test/AppTests +++ b/Library/Resources/Files/Templates/Test/AppTests.mustache @@ -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 diff --git a/Library/Sources/Internal/Enumerations/File.swift b/Library/Sources/Internal/Enumerations/File.swift index 861b114..50e68f0 100644 --- a/Library/Sources/Internal/Enumerations/File.swift +++ b/Library/Sources/Internal/Enumerations/File.swift @@ -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 } } diff --git a/Library/Sources/Internal/Enumerations/Template.swift b/Library/Sources/Internal/Enumerations/Template.swift new file mode 100644 index 0000000..4d45a7c --- /dev/null +++ b/Library/Sources/Internal/Enumerations/Template.swift @@ -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 {} diff --git a/Library/Sources/Internal/Tasks/RunProcessTask.swift b/Library/Sources/Internal/Tasks/RunProcessTask.swift deleted file mode 100644 index ea267b4..0000000 --- a/Library/Sources/Internal/Tasks/RunProcessTask.swift +++ /dev/null @@ -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 -} diff --git a/Library/Sources/Public/Models/Project.swift b/Library/Sources/Public/Models/Project.swift new file mode 100644 index 0000000..730439c --- /dev/null +++ b/Library/Sources/Public/Models/Project.swift @@ -0,0 +1,13 @@ +public struct Project: Equatable, Sendable { + + // MARK: Properties + + let name: String + + // MARK: Initialisers + + public init(name: String) { + self.name = name + } + +} diff --git a/Library/Sources/Public/Protocols/Bundleable.swift b/Library/Sources/Public/Protocols/Bundleable.swift index 308682e..e6fe719 100644 --- a/Library/Sources/Public/Protocols/Bundleable.swift +++ b/Library/Sources/Public/Protocols/Bundleable.swift @@ -1,6 +1,10 @@ import Foundation public protocol Bundleable { + + // MARK: Computed + + var resourcePath: String? { get } // MARK: Functions diff --git a/Library/Sources/Public/Protocols/FileServicing.swift b/Library/Sources/Public/Protocols/FileServicing.swift index 4de66e3..db07292 100644 --- a/Library/Sources/Public/Protocols/FileServicing.swift +++ b/Library/Sources/Public/Protocols/FileServicing.swift @@ -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 diff --git a/Library/Sources/Public/Protocols/TemplateServicing.swift b/Library/Sources/Public/Protocols/TemplateServicing.swift new file mode 100644 index 0000000..24682e6 --- /dev/null +++ b/Library/Sources/Public/Protocols/TemplateServicing.swift @@ -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 +} diff --git a/Library/Sources/Public/Protocols/TerminalServicing.swift b/Library/Sources/Public/Protocols/TerminalServicing.swift new file mode 100644 index 0000000..b9f63e3 --- /dev/null +++ b/Library/Sources/Public/Protocols/TerminalServicing.swift @@ -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 +} + diff --git a/Library/Sources/Public/Services/FileService.swift b/Library/Sources/Public/Services/FileService.swift index d7e1fdb..47a3c0e 100644 --- a/Library/Sources/Public/Services/FileService.swift +++ b/Library/Sources/Public/Services/FileService.swift @@ -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 diff --git a/Library/Sources/Public/Services/TemplateService.swift b/Library/Sources/Public/Services/TemplateService.swift new file mode 100644 index 0000000..4afed81 --- /dev/null +++ b/Library/Sources/Public/Services/TemplateService.swift @@ -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 + } + +} + diff --git a/Library/Sources/Public/Services/TerminalService.swift b/Library/Sources/Public/Services/TerminalService.swift new file mode 100644 index 0000000..78105e1 --- /dev/null +++ b/Library/Sources/Public/Services/TerminalService.swift @@ -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) + } + } + +} diff --git a/Library/Sources/Public/Tasks/InitGitInFolderTask.swift b/Library/Sources/Public/Tasks/InitGitInFolderTask.swift index 850e1c2..fda011f 100644 --- a/Library/Sources/Public/Tasks/InitGitInFolderTask.swift +++ b/Library/Sources/Public/Tasks/InitGitInFolderTask.swift @@ -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"]) } } diff --git a/Library/Sources/Public/Tasks/RenderFilesTask.swift b/Library/Sources/Public/Tasks/RenderFilesTask.swift new file mode 100644 index 0000000..ae9500e --- /dev/null +++ b/Library/Sources/Public/Tasks/RenderFilesTask.swift @@ -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)) + } + } + +} diff --git a/Package.swift b/Package.swift index 9a07581..c42d31c 100644 --- a/Package.swift +++ b/Package.swift @@ -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") diff --git a/Test/Sources/Cases/Internal/Enumerations/FileTests.swift b/Test/Sources/Cases/Internal/Enumerations/FileTests.swift index 8e3b884..0de6680 100644 --- a/Test/Sources/Cases/Internal/Enumerations/FileTests.swift +++ b/Test/Sources/Cases/Internal/Enumerations/FileTests.swift @@ -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" ] } } - diff --git a/Test/Sources/Cases/Internal/Enumerations/TemplateTests.swift b/Test/Sources/Cases/Internal/Enumerations/TemplateTests.swift new file mode 100644 index 0000000..6476eed --- /dev/null +++ b/Test/Sources/Cases/Internal/Enumerations/TemplateTests.swift @@ -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, + ] + } +} diff --git a/Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift b/Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift deleted file mode 100644 index f493e1f..0000000 --- a/Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift +++ /dev/null @@ -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") - ] - } -} diff --git a/Test/Sources/Cases/Public/Services/FileServiceTests.swift b/Test/Sources/Cases/Public/Services/FileServiceTests.swift index bf8f909..2292f71 100644 --- a/Test/Sources/Cases/Public/Services/FileServiceTests.swift +++ b/Test/Sources/Cases/Public/Services/FileServiceTests.swift @@ -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 diff --git a/Test/Sources/Cases/Public/Services/TemplateServiceTests.swift b/Test/Sources/Cases/Public/Services/TemplateServiceTests.swift new file mode 100644 index 0000000..3bd7852 --- /dev/null +++ b/Test/Sources/Cases/Public/Services/TemplateServiceTests.swift @@ -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 = "" +} diff --git a/Test/Sources/Cases/Public/Services/TerminalServiceTests.swift b/Test/Sources/Cases/Public/Services/TerminalServiceTests.swift new file mode 100644 index 0000000..f644c2b --- /dev/null +++ b/Test/Sources/Cases/Public/Services/TerminalServiceTests.swift @@ -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 = "" +} diff --git a/Test/Sources/Cases/Public/Tasks/InitGitInFolderTaskTests.swift b/Test/Sources/Cases/Public/Tasks/InitGitInFolderTaskTests.swift new file mode 100644 index 0000000..c7ef73e --- /dev/null +++ b/Test/Sources/Cases/Public/Tasks/InitGitInFolderTaskTests.swift @@ -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"])) + } + +} diff --git a/Test/Sources/Cases/Public/Tasks/RenderFilesTaskTests.swift b/Test/Sources/Cases/Public/Tasks/RenderFilesTaskTests.swift new file mode 100644 index 0000000..08cc1ed --- /dev/null +++ b/Test/Sources/Cases/Public/Tasks/RenderFilesTaskTests.swift @@ -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.") + } + } + } + +} diff --git a/Test/Sources/Helpers/Mocks/FileServiceMock.swift b/Test/Sources/Helpers/Mocks/FileServiceMock.swift index 99bd117..e0e70dd 100644 --- a/Test/Sources/Helpers/Mocks/FileServiceMock.swift +++ b/Test/Sources/Helpers/Mocks/FileServiceMock.swift @@ -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) diff --git a/Test/Sources/Helpers/Mocks/TemplateServiceMock.swift b/Test/Sources/Helpers/Mocks/TemplateServiceMock.swift new file mode 100644 index 0000000..3b03345 --- /dev/null +++ b/Test/Sources/Helpers/Mocks/TemplateServiceMock.swift @@ -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 = "" +} diff --git a/Test/Sources/Helpers/Mocks/TerminalServiceMock.swift b/Test/Sources/Helpers/Mocks/TerminalServiceMock.swift new file mode 100644 index 0000000..3a1344d --- /dev/null +++ b/Test/Sources/Helpers/Mocks/TerminalServiceMock.swift @@ -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 = "" +} diff --git a/Test/Sources/Helpers/Spies/FileServiceSpy.swift b/Test/Sources/Helpers/Spies/FileServiceSpy.swift index 34ccd63..3b9e90f 100644 --- a/Test/Sources/Helpers/Spies/FileServiceSpy.swift +++ b/Test/Sources/Helpers/Spies/FileServiceSpy.swift @@ -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) diff --git a/Test/Sources/Helpers/Spies/TemplateServiceSpy.swift b/Test/Sources/Helpers/Spies/TemplateServiceSpy.swift new file mode 100644 index 0000000..cc8a0c5 --- /dev/null +++ b/Test/Sources/Helpers/Spies/TemplateServiceSpy.swift @@ -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 = "" +} diff --git a/Test/Sources/Helpers/Spies/TerminalServiceSpy.swift b/Test/Sources/Helpers/Spies/TerminalServiceSpy.swift new file mode 100644 index 0000000..0c9a621 --- /dev/null +++ b/Test/Sources/Helpers/Spies/TerminalServiceSpy.swift @@ -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 = "" +}