From 9be8fa4a312ffdf3ac006cd1771a07c1137a7b06 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 28 Jan 2025 00:07:24 +0000 Subject: [PATCH] Basic project creation (#3) This PR contains the work done to create a new *Hummingbird* project with very basic configuration from the _colibri_ executable, just like the project you could create with the [Hummingbird template](https://github.com/hummingbird-project/template) project in Github. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/colibri/pulls/3 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Sources}/Colibri.swift | 1 - .../Sources/Commands/CreateCommand.swift | 35 +-- .../Sources/Options/CreateOptions.swift | 22 ++ Library/Resources/Files/Sources/App/App | 20 ++ .../Resources/Files/Sources/App/AppOptions | 20 ++ Library/Resources/Files/Sources/DockerFile | 87 ++++++++ Library/Resources/Files/Sources/DockerIgnore | 2 + Library/Resources/Files/Sources/GitIgnore | 10 + .../Files/Sources/Library/AppArguments | 11 + .../Files/Sources/Library/AppBuilder | 69 ++++++ .../Files/Sources/Library/Environment | 11 + .../Files/Sources/Library/LoggerLevel | 9 + Library/Resources/Files/Sources/License | 201 ++++++++++++++++++ Library/Resources/Files/Sources/Package | 45 ++++ Library/Resources/Files/Sources/Readme | 10 + Library/Resources/Files/Sources/Test/AppTests | 33 +++ .../Files/Sources/Test/TestArguments | 12 ++ .../Sources/Internal/Enumerations/File.swift | 74 +++++++ .../Internal/Enumerations/Folder.swift | 48 +++++ .../Extensions/Bundle+Conformances.swift | 5 + .../Internal/Extensions/Pipe+Properties.swift | 72 +++++++ .../Extensions/Process+Conformances.swift | 5 + .../Internal/Extensions/URL+Extensions.swift | 35 +++ .../Internal/Protocols/Processable.swift | 17 ++ .../Internal/Tasks/RunProcessTask.swift | 75 +++++++ .../Sources/Public/Protocols/Bundleable.swift | 9 + .../Public/Protocols/FileServicing.swift | 28 +++ .../Sources/Public/Services/FileService.swift | 79 +++++++ .../Sources/Public/Tasks/CopyFilesTask.swift | 39 ++++ .../Public/Tasks/CreateFoldersTask.swift | 25 +++ .../Public}/Tasks/CreateRootFolderTask.swift | 13 +- .../Public/Tasks/InitGitInFolderTask.swift | 24 +++ Package.swift | 33 ++- Sources/Library/ColibriLibrary.swift | 1 - Sources/Library/Extensions/URL+Inits.swift | 15 -- Sources/Library/Protocols/FileServicing.swift | 25 --- Sources/Library/Services/FileService.swift | 78 ------- .../Internal/Enumerations/FileTests.swift | 125 +++++++++++ .../Internal/Enumerations/FolderTests.swift | 35 +++ .../Extensions/URL+ExtensionsTests.swift | 76 +++++++ .../Internal/Tasks/RunProcessTaskTests.swift | 68 ++++++ .../Public/Services/FileServiceTests.swift | 181 ++++++++++++++++ .../Public/Tasks/CopyFilesTaskTests.swift | 80 +++++++ .../Public/Tasks/CreateFoldersTaskTests.swift | 49 +++++ .../Tasks/CreateRootFolderTaskTests.swift | 31 ++- .../Helpers/Extensions/URL+Samples.swift | 0 .../Helpers/Mocks/FileServiceMock.swift | 135 ++++++++++++ .../Helpers/Spies/FileServiceSpy.swift | 51 +++++ .../Cases/Services/FileServiceTests.swift | 154 -------------- Tests/Library/ColibriLibraryTests.swift | 3 - .../Helpers/Mocks/FileServiceMock.swift | 84 -------- .../Helpers/Spies/FileServiceSpy.swift | 41 ---- 52 files changed, 1936 insertions(+), 475 deletions(-) rename {Sources/Executable => Executable/Sources}/Colibri.swift (92%) rename Sources/Executable/Commands/Create.swift => Executable/Sources/Commands/CreateCommand.swift (62%) create mode 100644 Executable/Sources/Options/CreateOptions.swift create mode 100644 Library/Resources/Files/Sources/App/App create mode 100644 Library/Resources/Files/Sources/App/AppOptions create mode 100644 Library/Resources/Files/Sources/DockerFile create mode 100644 Library/Resources/Files/Sources/DockerIgnore create mode 100644 Library/Resources/Files/Sources/GitIgnore create mode 100644 Library/Resources/Files/Sources/Library/AppArguments create mode 100644 Library/Resources/Files/Sources/Library/AppBuilder create mode 100644 Library/Resources/Files/Sources/Library/Environment create mode 100644 Library/Resources/Files/Sources/Library/LoggerLevel create mode 100644 Library/Resources/Files/Sources/License create mode 100644 Library/Resources/Files/Sources/Package create mode 100644 Library/Resources/Files/Sources/Readme create mode 100644 Library/Resources/Files/Sources/Test/AppTests create mode 100644 Library/Resources/Files/Sources/Test/TestArguments create mode 100644 Library/Sources/Internal/Enumerations/File.swift create mode 100644 Library/Sources/Internal/Enumerations/Folder.swift create mode 100644 Library/Sources/Internal/Extensions/Bundle+Conformances.swift create mode 100644 Library/Sources/Internal/Extensions/Pipe+Properties.swift create mode 100644 Library/Sources/Internal/Extensions/Process+Conformances.swift create mode 100644 Library/Sources/Internal/Extensions/URL+Extensions.swift create mode 100644 Library/Sources/Internal/Protocols/Processable.swift create mode 100644 Library/Sources/Internal/Tasks/RunProcessTask.swift create mode 100644 Library/Sources/Public/Protocols/Bundleable.swift create mode 100644 Library/Sources/Public/Protocols/FileServicing.swift create mode 100644 Library/Sources/Public/Services/FileService.swift create mode 100644 Library/Sources/Public/Tasks/CopyFilesTask.swift create mode 100644 Library/Sources/Public/Tasks/CreateFoldersTask.swift rename {Sources/Library => Library/Sources/Public}/Tasks/CreateRootFolderTask.swift (70%) create mode 100644 Library/Sources/Public/Tasks/InitGitInFolderTask.swift delete mode 100644 Sources/Library/ColibriLibrary.swift delete mode 100644 Sources/Library/Extensions/URL+Inits.swift delete mode 100644 Sources/Library/Protocols/FileServicing.swift delete mode 100644 Sources/Library/Services/FileService.swift create mode 100644 Test/Sources/Cases/Internal/Enumerations/FileTests.swift create mode 100644 Test/Sources/Cases/Internal/Enumerations/FolderTests.swift create mode 100644 Test/Sources/Cases/Internal/Extensions/URL+ExtensionsTests.swift create mode 100644 Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift create mode 100644 Test/Sources/Cases/Public/Services/FileServiceTests.swift create mode 100644 Test/Sources/Cases/Public/Tasks/CopyFilesTaskTests.swift create mode 100644 Test/Sources/Cases/Public/Tasks/CreateFoldersTaskTests.swift rename {Tests/Library/Cases => Test/Sources/Cases/Public}/Tasks/CreateRootFolderTaskTests.swift (77%) rename {Tests/Library => Test/Sources}/Helpers/Extensions/URL+Samples.swift (100%) create mode 100644 Test/Sources/Helpers/Mocks/FileServiceMock.swift create mode 100644 Test/Sources/Helpers/Spies/FileServiceSpy.swift delete mode 100644 Tests/Library/Cases/Services/FileServiceTests.swift delete mode 100644 Tests/Library/ColibriLibraryTests.swift delete mode 100644 Tests/Library/Helpers/Mocks/FileServiceMock.swift delete mode 100644 Tests/Library/Helpers/Spies/FileServiceSpy.swift diff --git a/Sources/Executable/Colibri.swift b/Executable/Sources/Colibri.swift similarity index 92% rename from Sources/Executable/Colibri.swift rename to Executable/Sources/Colibri.swift index 8a39244..194d287 100644 --- a/Sources/Executable/Colibri.swift +++ b/Executable/Sources/Colibri.swift @@ -1,5 +1,4 @@ import ArgumentParser -import ColibriLibrary @main struct Colibri: AsyncParsableCommand { diff --git a/Sources/Executable/Commands/Create.swift b/Executable/Sources/Commands/CreateCommand.swift similarity index 62% rename from Sources/Executable/Commands/Create.swift rename to Executable/Sources/Commands/CreateCommand.swift index a8d4373..9f35af7 100644 --- a/Sources/Executable/Commands/Create.swift +++ b/Executable/Sources/Commands/CreateCommand.swift @@ -1,6 +1,5 @@ import ArgumentParser import ColibriLibrary -import Foundation extension Colibri { struct Create: AsyncParsableCommand { @@ -13,43 +12,27 @@ extension Colibri { helpNames: .shortAndLong, aliases: ["create"] ) - + @OptionGroup var options: Options // MARK: Functions mutating func run() async throws { let fileService = FileService() + + let copyFiles = CopyFilesTask(fileService: fileService) + let createFolders = CreateFoldersTask(fileService: fileService) let createRootFolder = CreateRootFolderTask(fileService: fileService) + let initGitInFolder = InitGitInFolderTask() let rootFolder = try await createRootFolder( name: options.name, at: options.locationURL ) - - print(rootFolder) - } - - } -} - -// MARK: - Options - -extension Colibri.Create { - struct Options: ParsableArguments { - - // MARK: Properties - - @Option(name: .shortAndLong) - var name: String - - @Option(name: .shortAndLong) - var location: String? - - // MARK: Computed - - var locationURL: URL? { - location.flatMap { URL(fileURLWithPath: $0) } + + try await createFolders(at: rootFolder) + try await copyFiles(to: rootFolder) + try await initGitInFolder(at: rootFolder) } } diff --git a/Executable/Sources/Options/CreateOptions.swift b/Executable/Sources/Options/CreateOptions.swift new file mode 100644 index 0000000..8050ea8 --- /dev/null +++ b/Executable/Sources/Options/CreateOptions.swift @@ -0,0 +1,22 @@ +import ArgumentParser +import Foundation + +extension Colibri.Create { + struct Options: ParsableArguments { + + // MARK: Properties + + @Option(name: .shortAndLong) + var name: String + + @Option(name: .shortAndLong) + var location: String? + + // MARK: Computed + + var locationURL: URL? { + location.flatMap { URL(fileURLWithPath: $0) } + } + + } +} diff --git a/Library/Resources/Files/Sources/App/App b/Library/Resources/Files/Sources/App/App new file mode 100644 index 0000000..9183ae7 --- /dev/null +++ b/Library/Resources/Files/Sources/App/App @@ -0,0 +1,20 @@ +import AppLibrary +import ArgumentParser + +@main +struct App: AsyncParsableCommand { + + // MARK: Properties + + @OptionGroup var options: Options + + // MARK: Functions + + mutating func run() async throws { + let builder = AppBuilder(name: "App") + let app = try await builder(options) + + try await app.runService() + } + +} diff --git a/Library/Resources/Files/Sources/App/AppOptions b/Library/Resources/Files/Sources/App/AppOptions new file mode 100644 index 0000000..a6d835f --- /dev/null +++ b/Library/Resources/Files/Sources/App/AppOptions @@ -0,0 +1,20 @@ +import AppLibrary +import ArgumentParser +import Logging + +extension App { + struct Options: AppArguments, ParsableArguments { + + // MARK: Properties + + @Option(name: .shortAndLong) + var hostname: String = "127.0.0.1" + + @Option(name: .shortAndLong) + var port: Int = 8080 + + @Option(name: .shortAndLong) + var logLevel: Logger.Level? + + } +} diff --git a/Library/Resources/Files/Sources/DockerFile b/Library/Resources/Files/Sources/DockerFile new file mode 100644 index 0000000..a20ddca --- /dev/null +++ b/Library/Resources/Files/Sources/DockerFile @@ -0,0 +1,87 @@ +# ================================ +# Build image +# ================================ +FROM swift:6.0-noble as build + +# Install OS updates +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get install -y libjemalloc-dev \ + && rm -rf /var/lib/apt/lists/* + +# Set up a build area +WORKDIR /build + +# First just resolve dependencies. +# This creates a cached layer that can be reused +# as long as your Package.swift/Package.resolved +# files do not change. +COPY ./Package.* ./ +RUN swift package resolve + +# Copy entire repo into container +COPY . . + +# Build the application, with optimizations, with static linking, and using jemalloc +RUN swift build -c release \ + --product "App" \ + --static-swift-stdlib \ + -Xlinker -ljemalloc + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area +RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./ + +# Copy static swift backtracer binary to staging area +RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ + +# Copy resources bundled by SPM to staging area +RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; + +# Copy any resources from the public directory and views directory if the directories exist +# Ensure that by default, neither the directory nor any of its contents are writable. +RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true + +# ================================ +# Run image +# ================================ +FROM ubuntu:noble + +# Make sure all system packages are up to date, and install only essential packages. +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get -q install -y \ + libjemalloc2 \ + ca-certificates \ + tzdata \ +# If your app or its dependencies import FoundationNetworking, also install `libcurl4`. + # libcurl4 \ +# If your app or its dependencies import FoundationXML, also install `libxml2`. + # libxml2 \ + && rm -r /var/lib/apt/lists/* + +# Create a hummingbird user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=hummingbird:hummingbird /staging /app + +# Provide configuration needed by the built-in crash reporter and some sensible default behaviors. +ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static + +# Ensure all further commands run as the hummingbird user +USER hummingbird:hummingbird + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment +ENTRYPOINT ["./App] +CMD ["--hostname", "0.0.0.0", "--port", "8080"] diff --git a/Library/Resources/Files/Sources/DockerIgnore b/Library/Resources/Files/Sources/DockerIgnore new file mode 100644 index 0000000..4e05543 --- /dev/null +++ b/Library/Resources/Files/Sources/DockerIgnore @@ -0,0 +1,2 @@ +.build +.git diff --git a/Library/Resources/Files/Sources/GitIgnore b/Library/Resources/Files/Sources/GitIgnore new file mode 100644 index 0000000..edcd2d6 --- /dev/null +++ b/Library/Resources/Files/Sources/GitIgnore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/.devContainer +/.swiftpm +/.vscode +/Packages +/*.xcodeproj +xcuserdata/ +.env.* +.env diff --git a/Library/Resources/Files/Sources/Library/AppArguments b/Library/Resources/Files/Sources/Library/AppArguments new file mode 100644 index 0000000..40039e1 --- /dev/null +++ b/Library/Resources/Files/Sources/Library/AppArguments @@ -0,0 +1,11 @@ +import Logging + +public protocol AppArguments { + + // MARK: Properties + + var hostname: String { get } + var logLevel: Logger.Level? { get } + var port: Int { get } + +} diff --git a/Library/Resources/Files/Sources/Library/AppBuilder b/Library/Resources/Files/Sources/Library/AppBuilder new file mode 100644 index 0000000..1998089 --- /dev/null +++ b/Library/Resources/Files/Sources/Library/AppBuilder @@ -0,0 +1,69 @@ +import Hummingbird +import Logging + +public struct AppBuilder { + + // MARK: Properties + + private let environment: Environment + private let name: String + + // MARK: Initialisers + + public init(name: String) { + self.environment = Environment() + self.name = name + } + + // MARK: Functions + + public func callAsFunction( + _ arguments: some AppArguments + ) async throws -> some ApplicationProtocol { + let logger = { + var logger = Logger(label: name) + + logger.logLevel = arguments.logLevel + ?? environment.logLevel.flatMap { Logger.Level(rawValue: $0) ?? .info } + ?? .info + + return logger + }() + + let router = router(logger: logger) + + return Application( + router: router, + configuration: .init( + address: .hostname(arguments.hostname, port: arguments.port), + serverName: name + ), + logger: logger + ) + } + +} + +// MARK: - Helpers + +private extension AppBuilder { + + // MARK: Type aliases + + typealias AppRequestContext = BasicRequestContext + + // MARK: Functions + + func router(logger: Logger) -> Router { + let router = Router() + + router.add(middleware: LogRequestsMiddleware(logger.logLevel)) + + router.get("/") { _,_ in + "" + } + + return router + } + +} diff --git a/Library/Resources/Files/Sources/Library/Environment b/Library/Resources/Files/Sources/Library/Environment new file mode 100644 index 0000000..99d4d41 --- /dev/null +++ b/Library/Resources/Files/Sources/Library/Environment @@ -0,0 +1,11 @@ +import Hummingbird + +extension Environment { + + // MARK: Computed + + public var logLevel: String? { + self.get("LOG_LEVEL") + } + +} diff --git a/Library/Resources/Files/Sources/Library/LoggerLevel b/Library/Resources/Files/Sources/Library/LoggerLevel new file mode 100644 index 0000000..0d1abb6 --- /dev/null +++ b/Library/Resources/Files/Sources/Library/LoggerLevel @@ -0,0 +1,9 @@ +import ArgumentParser +import Logging + +/// Extend `Logger.Level` so it can be used as an argument +#if hasFeature(RetroactiveAttribute) +extension Logger.Level: @retroactive ExpressibleByArgument {} +#else +extension Logger.Level: ExpressibleByArgument {} +#endif diff --git a/Library/Resources/Files/Sources/License b/Library/Resources/Files/Sources/License new file mode 100644 index 0000000..bea052c --- /dev/null +++ b/Library/Resources/Files/Sources/License @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Adam Fowler + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Library/Resources/Files/Sources/Package b/Library/Resources/Files/Sources/Package new file mode 100644 index 0000000..0c804dc --- /dev/null +++ b/Library/Resources/Files/Sources/Package @@ -0,0 +1,45 @@ +// swift-tools-version:6.0 + +import PackageDescription + +let package = Package( + name: "App", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "App", targets: ["App"]), + .library(name: "AppLibrary", targets: ["AppLibrary"]) + ], + dependencies: [ + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0") + ], + targets: [ + .executableTarget( + name: "App", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Hummingbird", package: "hummingbird"), + .target(name: "AppLibrary") + ], + path: "App" + ), + .target( + name: "AppLibrary", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Hummingbird", package: "hummingbird") + ], + path: "Library" + ), + .testTarget( + name: "AppTests", + dependencies: [ + .product(name: "HummingbirdTesting", package: "hummingbird"), + .target(name: "AppLibrary") + ], + path: "Test" + ) + ] +) diff --git a/Library/Resources/Files/Sources/Readme b/Library/Resources/Files/Sources/Readme new file mode 100644 index 0000000..e13e70d --- /dev/null +++ b/Library/Resources/Files/Sources/Readme @@ -0,0 +1,10 @@ +

+ + + + +

+ +# Hummingbird project template + +This is a template for your new [Hummingbird](https://wwww.hummingbird.codes) project. diff --git a/Library/Resources/Files/Sources/Test/AppTests b/Library/Resources/Files/Sources/Test/AppTests new file mode 100644 index 0000000..31f1868 --- /dev/null +++ b/Library/Resources/Files/Sources/Test/AppTests @@ -0,0 +1,33 @@ +import AppLibrary +import Hummingbird +import HummingbirdTesting +import Testing + +struct AppTests { + + // MARK: Properties + + private let arguments = TestArguments() + private let builder = AppBuilder(name: "App") + + // MARK: Route tests + + @Test(arguments: ["/"]) + func routes(_ uri: String) async throws { + let app = try await builder(arguments) + + try await app.test(.router) { client in + try await client.execute(uri: uri, method: .get) { response in + #expect(response.status == .ok) + #expect(response.body == .empty) + } + } + } + +} + +// MARK: ByteBuffer+Constants + +private extension ByteBuffer { + static let empty = ByteBuffer(string: "") +} diff --git a/Library/Resources/Files/Sources/Test/TestArguments b/Library/Resources/Files/Sources/Test/TestArguments new file mode 100644 index 0000000..c2f0b21 --- /dev/null +++ b/Library/Resources/Files/Sources/Test/TestArguments @@ -0,0 +1,12 @@ +import AppLibrary +import Logging + +struct TestArguments: AppArguments { + + // MARK: Properties + + let hostname = "127.0.0.1" + let port = 0 + let logLevel: Logger.Level? = .trace + +} diff --git a/Library/Sources/Internal/Enumerations/File.swift b/Library/Sources/Internal/Enumerations/File.swift new file mode 100644 index 0000000..861b114 --- /dev/null +++ b/Library/Sources/Internal/Enumerations/File.swift @@ -0,0 +1,74 @@ +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" + +} + +// MARK: - Properties + +extension File { + + // MARK: Computed + + 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" + case .gitIgnore: ".gitignore" + case .license: "LICENSE" + case .loggerLevel: "LoggerLevel+Conformances.swift" + case .readme: "README.md" + case .package: "Package.swift" + case .testArguments: "TestArguments.swift" + } + } + + var filePath: String { + folder.path + fileName + } + + var folder: Folder { + switch self { + case .app, .appOptions: .app + case .appArguments, .appBuilder: .libraryPublic + case .appTests: .testCasesPublic + case .environment, .loggerLevel: .libraryInternal + case .testArguments: .testHelpers + default: .root + } + } + + var resourcePath: String { + let basePath = "Resources/Files/Sources" + + return switch self { + case .app, .appOptions: "\(basePath)/App" + case .appArguments, .appBuilder, .environment, .loggerLevel: "\(basePath)/Library" + case .appTests, .testArguments: "\(basePath)/Test" + default: basePath + } + } + +} + +// MARK: - CaseIterable + +extension File: CaseIterable {} diff --git a/Library/Sources/Internal/Enumerations/Folder.swift b/Library/Sources/Internal/Enumerations/Folder.swift new file mode 100644 index 0000000..ba0bad6 --- /dev/null +++ b/Library/Sources/Internal/Enumerations/Folder.swift @@ -0,0 +1,48 @@ +enum Folder { + case app + case libraryPublic + case libraryInternal + case root + case testCasesPublic + case testCasesInternal + case testHelpers +} + +// MARK: - Properties + +extension Folder { + + // MARK: Computed + + var path: String { + switch self { + case .app: "App/Sources/" + case .libraryPublic: "Library/Sources/Public/" + case .libraryInternal: "Library/Sources/Internal/" + case .root: "" + case .testCasesPublic: "Test/Sources/Cases/Public/" + case .testCasesInternal: "Test/Sources/Cases/Internal/" + case .testHelpers: "Test/Sources/Helpers/" + } + } + +} + +// MARK: - CaseIterable + +extension Folder: CaseIterable { + + // MARK: Properties + + static var allCases: [Folder] {[ + .app, + .libraryPublic, + .libraryInternal, + .testCasesPublic, + .testCasesInternal, + .testHelpers + ]} + + static var allCasesWithRoot: [Folder] { [.root] + Folder.allCases } + +} diff --git a/Library/Sources/Internal/Extensions/Bundle+Conformances.swift b/Library/Sources/Internal/Extensions/Bundle+Conformances.swift new file mode 100644 index 0000000..d8b5fbc --- /dev/null +++ b/Library/Sources/Internal/Extensions/Bundle+Conformances.swift @@ -0,0 +1,5 @@ +import Foundation + +// MARK: - Bundleable + +extension Bundle: Bundleable {} diff --git a/Library/Sources/Internal/Extensions/Pipe+Properties.swift b/Library/Sources/Internal/Extensions/Pipe+Properties.swift new file mode 100644 index 0000000..94b27e7 --- /dev/null +++ b/Library/Sources/Internal/Extensions/Pipe+Properties.swift @@ -0,0 +1,72 @@ +import Foundation + +extension Pipe { + + // MARK: Computed + + var availableData: AsyncAvailableData { .init(self) } + +} + +// MARK: - AsyncAvailableData + +extension Pipe { + struct AsyncAvailableData { + + // MARK: Properties + + private let pipe: Pipe + + // MARK: Initialisers + + init(_ pipe: Pipe) { + self.pipe = pipe + } + + // MARK: Functions + + func append() async -> Data { + var data = Data() + + for await availableData in self { + data.append(availableData) + } + + return data + } + + } +} + +// MARK: - AsyncSequence + +extension Pipe.AsyncAvailableData: AsyncSequence { + + // MARK: Type aliases + + typealias AsyncIterator = AsyncStream.Iterator + typealias Element = Data + + // MARK: Functions + + func makeAsyncIterator() -> AsyncIterator { + AsyncStream { continuation in + pipe.fileHandleForReading.readabilityHandler = { @Sendable handler in + let data = handler.availableData + + guard !data.isEmpty else { + continuation.finish() + return + } + + continuation.yield(data) + } + + continuation.onTermination = { _ in + pipe.fileHandleForReading.readabilityHandler = nil + } + } + .makeAsyncIterator() + } + +} diff --git a/Library/Sources/Internal/Extensions/Process+Conformances.swift b/Library/Sources/Internal/Extensions/Process+Conformances.swift new file mode 100644 index 0000000..d5dfa7b --- /dev/null +++ b/Library/Sources/Internal/Extensions/Process+Conformances.swift @@ -0,0 +1,5 @@ +import Foundation + +// MARK: - Processable + +extension Process: Processable {} diff --git a/Library/Sources/Internal/Extensions/URL+Extensions.swift b/Library/Sources/Internal/Extensions/URL+Extensions.swift new file mode 100644 index 0000000..01d4ea3 --- /dev/null +++ b/Library/Sources/Internal/Extensions/URL+Extensions.swift @@ -0,0 +1,35 @@ +import Foundation + +extension URL { + + // MARK: Initialisers + + init(at filePath: String) { + if #available(macOS 13.0, *) { + self = URL(filePath: filePath) + } else { + self = URL(fileURLWithPath: filePath) + } + } + + // MARK: Computed + + var pathString: String { + if #available(macOS 13.0, *) { + path(percentEncoded: true) + } else { + path + } + } + + // MARK: Functions + + func appendingPath(_ path: String) -> URL { + if #available(macOS 13.0, *) { + appending(path: path) + } else { + appendingPathComponent(path) + } + } + +} diff --git a/Library/Sources/Internal/Protocols/Processable.swift b/Library/Sources/Internal/Protocols/Processable.swift new file mode 100644 index 0000000..42cb1b9 --- /dev/null +++ b/Library/Sources/Internal/Protocols/Processable.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol Processable { + + // MARK: Properties + + var arguments: [String]? { get set } + var executableURL: URL? { get set } + var standardError: Any? { get set } + var standardOutput: Any? { get set } + var terminationHandler: (@Sendable (Process) -> Void)? { get set } + + // MARK: Functions + + func run() throws + +} diff --git a/Library/Sources/Internal/Tasks/RunProcessTask.swift b/Library/Sources/Internal/Tasks/RunProcessTask.swift new file mode 100644 index 0000000..ea267b4 --- /dev/null +++ b/Library/Sources/Internal/Tasks/RunProcessTask.swift @@ -0,0 +1,75 @@ +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/Protocols/Bundleable.swift b/Library/Sources/Public/Protocols/Bundleable.swift new file mode 100644 index 0000000..308682e --- /dev/null +++ b/Library/Sources/Public/Protocols/Bundleable.swift @@ -0,0 +1,9 @@ +import Foundation + +public protocol Bundleable { + + // MARK: Functions + + func url(forResource name: String?, withExtension ext: String?, subdirectory subpath: String?) -> URL? + +} diff --git a/Library/Sources/Public/Protocols/FileServicing.swift b/Library/Sources/Public/Protocols/FileServicing.swift new file mode 100644 index 0000000..4de66e3 --- /dev/null +++ b/Library/Sources/Public/Protocols/FileServicing.swift @@ -0,0 +1,28 @@ +import Foundation + +public protocol FileServicing { + + // MARK: Computed + + var currentFolder: URL { get async } + + // MARK: Functions + + func copyFile(from source: URL, to destination: URL) 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 + +} + +// MARK: - Errors + +public enum FileServiceError: Error, Equatable { + case folderNotCreated + case itemAlreadyExists + case itemEmptyData + case itemNotCopied + case itemNotDeleted + case itemNotExists + case itemNotFileURL +} diff --git a/Library/Sources/Public/Services/FileService.swift b/Library/Sources/Public/Services/FileService.swift new file mode 100644 index 0000000..d7e1fdb --- /dev/null +++ b/Library/Sources/Public/Services/FileService.swift @@ -0,0 +1,79 @@ +import Foundation + +public struct FileService: FileServicing { + + // MARK: Properties + + private let fileManager: FileManager + + // MARK: Initialisers + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + // MARK: Computed + + public var currentFolder: URL { + get async { + .init(at: fileManager.currentDirectoryPath) + } + } + + // MARK: Functions + + public func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) { + guard try await !isItemExists(at: destination) else { + throw FileServiceError.itemAlreadyExists + } + + var itemData: Data? + + do { + itemData = try Data(contentsOf: source) + } catch { + throw FileServiceError.itemEmptyData + } + + do { + try itemData?.write(to: destination, options: .atomic) + } catch { + throw FileServiceError.itemNotCopied + } + } + + public func createFolder(at location: URL) async throws (FileServiceError) { + guard try await !isItemExists(at: location) else { + throw FileServiceError.itemAlreadyExists + } + + do { + try fileManager.createDirectory(at: location, withIntermediateDirectories: true) + } catch { + throw FileServiceError.folderNotCreated + } + } + + public func deleteItem(at location: URL) async throws (FileServiceError) { + guard try await isItemExists(at: location) else { + throw FileServiceError.itemNotExists + } + + do { + try fileManager.removeItem(at: location) + } catch { + throw FileServiceError.itemNotDeleted + } + } + + public func isItemExists(at location: URL) async throws (FileServiceError) -> Bool { + guard location.isFileURL else { + throw FileServiceError.itemNotFileURL + } + + let filePath = location.pathString + + return fileManager.fileExists(atPath: filePath) + } + +} diff --git a/Library/Sources/Public/Tasks/CopyFilesTask.swift b/Library/Sources/Public/Tasks/CopyFilesTask.swift new file mode 100644 index 0000000..3888d05 --- /dev/null +++ b/Library/Sources/Public/Tasks/CopyFilesTask.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct CopyFilesTask { + + // MARK: Properties + + private let bundle: Bundleable + private let fileService: FileServicing + + // MARK: Initialisers + + public init( + bundle: Bundleable? = nil, + fileService: FileServicing + ) { + self.bundle = bundle ?? Bundle.module + self.fileService = fileService + } + + // MARK: Functions + + public func callAsFunction(to rootFolder: URL) async throws (FileServiceError) { + for file in File.allCases { + guard let source = bundle.url( + forResource: file.rawValue, + withExtension: nil, + subdirectory: file.resourcePath + ) else { + assertionFailure("URL should have been initialized.") + return + } + + let destination = rootFolder.appendingPath(file.filePath) + + try await fileService.copyFile(from: source, to: destination) + } + } + +} diff --git a/Library/Sources/Public/Tasks/CreateFoldersTask.swift b/Library/Sources/Public/Tasks/CreateFoldersTask.swift new file mode 100644 index 0000000..2a39327 --- /dev/null +++ b/Library/Sources/Public/Tasks/CreateFoldersTask.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct CreateFoldersTask { + + // MARK: Properties + + private let fileService: FileServicing + + // MARK: Initialisers + + public init(fileService: FileServicing) { + self.fileService = fileService + } + + // MARK: Functions + + public func callAsFunction(at rootFolder: URL) async throws { + let folders = Folder.allCases.map { rootFolder.appendingPath($0.path) } + + for folder in folders { + try await fileService.createFolder(at: folder) + } + } + +} diff --git a/Sources/Library/Tasks/CreateRootFolderTask.swift b/Library/Sources/Public/Tasks/CreateRootFolderTask.swift similarity index 70% rename from Sources/Library/Tasks/CreateRootFolderTask.swift rename to Library/Sources/Public/Tasks/CreateRootFolderTask.swift index fdee7e0..06f6643 100644 --- a/Sources/Library/Tasks/CreateRootFolderTask.swift +++ b/Library/Sources/Public/Tasks/CreateRootFolderTask.swift @@ -14,10 +14,7 @@ public struct CreateRootFolderTask { // MARK: Functions - public func callAsFunction( - name: String, - at location: URL? = nil - ) async throws -> URL { + public func callAsFunction(name: String, at location: URL? = nil) async throws -> URL { guard !name.isEmpty else { throw CreateRootFolderError.nameIsEmpty } @@ -27,12 +24,8 @@ public struct CreateRootFolderTask { } else { await fileService.currentFolder } - - let newFolder = if #available(macOS 13.0, *) { - rootFolder.appending(path: name) - } else { - rootFolder.appendingPathComponent(name) - } + + let newFolder = rootFolder.appendingPath(name) try await fileService.createFolder(at: newFolder) diff --git a/Library/Sources/Public/Tasks/InitGitInFolderTask.swift b/Library/Sources/Public/Tasks/InitGitInFolderTask.swift new file mode 100644 index 0000000..850e1c2 --- /dev/null +++ b/Library/Sources/Public/Tasks/InitGitInFolderTask.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct InitGitInFolderTask { + + // MARK: Initialisers + + public init() {} + + // MARK: Functions + + public func callAsFunction(at rootFolder: URL) async throws (RunProcessError) { + let pathCommand = "/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"]) + } + +} diff --git a/Package.swift b/Package.swift index d8870c1..9a07581 100644 --- a/Package.swift +++ b/Package.swift @@ -8,42 +8,35 @@ let package = Package( .macOS(.v10_15) ], products: [ - .executable( - name: "colibri", - targets: ["Colibri"] - ), - .library( - name: "ColibriLibrary", - targets: ["ColibriLibrary"] - ) + .executable(name: "colibri", targets: ["Colibri"]), + .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") ], targets: [ .executableTarget( name: "Colibri", dependencies: [ - .product( - name: "ArgumentParser", - package: "swift-argument-parser" - ), + .product(name: "ArgumentParser", package: "swift-argument-parser"), .target(name: "ColibriLibrary") ], - path: "Sources/Executable" + path: "Executable" ), .target( name: "ColibriLibrary", dependencies: [], - path: "Sources/Library" + path: "Library", + resources: [ + .copy("Resources") + ] ), .testTarget( name: "ColibriTests", - dependencies: ["ColibriLibrary"], - path: "Tests/Library" + dependencies: [ + .target(name: "ColibriLibrary") + ], + path: "Test" ) ] ) diff --git a/Sources/Library/ColibriLibrary.swift b/Sources/Library/ColibriLibrary.swift deleted file mode 100644 index fecc4ab..0000000 --- a/Sources/Library/ColibriLibrary.swift +++ /dev/null @@ -1 +0,0 @@ -import Foundation diff --git a/Sources/Library/Extensions/URL+Inits.swift b/Sources/Library/Extensions/URL+Inits.swift deleted file mode 100644 index 7c31114..0000000 --- a/Sources/Library/Extensions/URL+Inits.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -extension URL { - - // MARK: Initialisers - - init(at filePath: String) { - if #available(macOS 13.0, *) { - self = URL(filePath: filePath) - } else { - self = URL(fileURLWithPath: filePath) - } - } - -} diff --git a/Sources/Library/Protocols/FileServicing.swift b/Sources/Library/Protocols/FileServicing.swift deleted file mode 100644 index 5f85840..0000000 --- a/Sources/Library/Protocols/FileServicing.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -public protocol FileServicing { - - // MARK: Computed - - var currentFolder: URL { get async } - - // MARK: Functions - - func createFolder(at url: URL) async throws (FileServiceError) - func delete(at url: URL) async throws (FileServiceError) - func exists(at url: URL) async throws (FileServiceError) -> Bool - -} - -// MARK: - Errors - -public enum FileServiceError: Error, Equatable { - case folderNotCreated - case urlAlreadyExists - case urlNotDeleted - case urlNotExists - case urlNotFileURL -} diff --git a/Sources/Library/Services/FileService.swift b/Sources/Library/Services/FileService.swift deleted file mode 100644 index 3e91ad2..0000000 --- a/Sources/Library/Services/FileService.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -public struct FileService: FileServicing { - - // MARK: Properties - - private let fileManager: FileManager - - // MARK: Initialisers - - public init(fileManager: FileManager = .default) { - self.fileManager = fileManager - } - - // MARK: Computed - - public var currentFolder: URL { - get async { - .init(at: fileManager.currentDirectoryPath) - } - } - - // MARK: Functions - - public func createFolder(at url: URL) async throws (FileServiceError) { - guard try await !exists(at: url) else { - throw FileServiceError.urlAlreadyExists - } - - do { - try fileManager.createDirectory( - at: url, - withIntermediateDirectories: true - ) - } catch { - throw FileServiceError.folderNotCreated - } - } - - public func delete(at url: URL) async throws (FileServiceError) { - guard try await exists(at: url) else { - throw FileServiceError.urlNotExists - } - - do { - try fileManager.removeItem(at: url) - } catch { - throw FileServiceError.urlNotDeleted - } - } - - public func exists(at url: URL) async throws (FileServiceError) -> Bool { - guard url.isFileURL else { - throw FileServiceError.urlNotFileURL - } - - let filePath = getPath(for: url) - - return fileManager.fileExists(atPath: filePath) - } - -} - -// MARK: - Helpers - -private extension FileService { - - // MARK: Functions - - func getPath(for url: URL) -> String { - if #available(macOS 13.0, *) { - return url.path() - } else { - return url.path - } - } - -} diff --git a/Test/Sources/Cases/Internal/Enumerations/FileTests.swift b/Test/Sources/Cases/Internal/Enumerations/FileTests.swift new file mode 100644 index 0000000..8e3b884 --- /dev/null +++ b/Test/Sources/Cases/Internal/Enumerations/FileTests.swift @@ -0,0 +1,125 @@ +import Testing + +@testable import ColibriLibrary + +struct FileTests { + + // MARK: Properties tests + + @Test(arguments: zip(File.allCases, Expectation.fileNames)) + func fileName(for file: File, expects fileName: String) async throws { + // GIVEN + // WHEN + let result = file.fileName + + // THEN + #expect(result == fileName) + } + + @Test(arguments: zip(File.allCases, Expectation.filePaths)) + func filePath(for file: File, expects filePath: String) async throws { + // GIVEN + // WHEN + let result = file.filePath + + // THEN + #expect(result == filePath) + } + + @Test(arguments: zip(File.allCases, Expectation.folders)) + func folder(for file: File, expects folder: Folder) async throws { + // GIVEN + // WHEN + let result = file.folder + + // THEN + #expect(result == folder) + } + + @Test(arguments: zip(File.allCases, Expectation.resourcePaths)) + func resourcePath(for file: File, expects resourcePath: String) async throws { + // GIVEN + // WHEN + let result = file.resourcePath + + // THEN + #expect(result == resourcePath) + } + +} + +// MARK: - Expectations + +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, + .root, + .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", + "Resources/Files/Sources", + "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/FolderTests.swift b/Test/Sources/Cases/Internal/Enumerations/FolderTests.swift new file mode 100644 index 0000000..052c6d1 --- /dev/null +++ b/Test/Sources/Cases/Internal/Enumerations/FolderTests.swift @@ -0,0 +1,35 @@ +import Testing + +@testable import ColibriLibrary + +struct FolderTests { + + // MARK: Properties tests + + @Test(arguments: zip(Folder.allCasesWithRoot, Expectation.paths)) + func paths(for folder: Folder, expects path: String) async throws { + // GIVEN + // WHEN + let result = folder.path + + // THEN + #expect(result == path) + } + +} + +// MARK: - Expectations + +private extension FolderTests { + enum Expectation { + static let paths: [String] = [ + "", + "App/Sources/", + "Library/Sources/Public/", + "Library/Sources/Internal/", + "Test/Sources/Cases/Public/", + "Test/Sources/Cases/Internal/", + "Test/Sources/Helpers/" + ] + } +} diff --git a/Test/Sources/Cases/Internal/Extensions/URL+ExtensionsTests.swift b/Test/Sources/Cases/Internal/Extensions/URL+ExtensionsTests.swift new file mode 100644 index 0000000..b5525ea --- /dev/null +++ b/Test/Sources/Cases/Internal/Extensions/URL+ExtensionsTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing + +@testable import ColibriLibrary + +struct URL_ExtensionsTests { + + // MARK: Initialisers tests + + @Test(arguments: zip([String.someFilePath, .dotPath, .tildePath], + [URL.someFile, .dotFile, .tildeFile])) + func initAt( + with filePath: String, + expects url: URL + ) async throws { + // GIVEN + // WHEN + let result = URL(at: filePath) + + // THEN + #expect(result == url) + #expect(result.isFileURL == true) + } + + // MARK: Computed tests + + @Test(arguments: zip([URL.someFile, .dotFile, .tildeFile, .someURL], + [String.someFilePath, .dotPath, .tildePath, .empty])) + func pathString( + with url: URL, + expects path: String + ) async throws { + // GIVEN + // WHEN + let result = url.pathString + + // THEN + #expect(result == path) + } + + // MARK: Functions tests + + @Test(arguments: zip([URL.dotFile, .tildeFile, .someFile], + [".\(String.someFilePath)", "~\(String.someFilePath)", "\(String.someFilePath)\(String.someFilePath)"])) + func appendingPath( + with url: URL, + expects path: String + ) async throws { + // GIVEN + // WHEN + let result = url.appendingPath(.someFilePath) + + // THEN + #expect(result.pathString == path) + #expect(result.isFileURL == true) + } + +} + +// MARK: - String+Constants + +private extension String { + static let dotPath = "." + static let empty = "" + static let tildePath = "~" + static let someFilePath = "/some/file/path" +} + +// MARK: - URL+Constants + +private extension URL { + static let dotFile = URL(at: .dotPath) + static let someFile = URL(at: .someFilePath) + static let someURL = URL(string: "https://some.url.path")! + static let tildeFile = URL(at: .tildePath) +} diff --git a/Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift b/Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift new file mode 100644 index 0000000..f493e1f --- /dev/null +++ b/Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..bf8f909 --- /dev/null +++ b/Test/Sources/Cases/Public/Services/FileServiceTests.swift @@ -0,0 +1,181 @@ +import ColibriLibrary +import Foundation +import Testing + +struct FileServiceTests { + + // MARK: Properties + + private let spy = FileServiceSpy() + + // MARK: Properties tests + + @Test func currentFolder() async { + // GIVEN + let url: URL = .someCurrentFolder + + let service = FileServiceMock(currentFolder: url) + + // WHEN + let folder = await service.currentFolder + + // THEN + #expect(folder == url) + #expect(folder.isFileURL == true) + } + + // MARK: Functions tests + + @Test(arguments: zip([URL.someExistingFile, .someExistingFolder], + [URL.someNewFile, .someNewFolder])) + func copyFile(from source: URL, to destination: URL) async throws { + // GIVEN + let service = service(action: .copyFile(source, destination)) + + // WHEN + try await service.copyFile(from: source, to: destination) + + // THENn + #expect(spy.actions.count == 1) + + let action = try #require(spy.actions.last) + + #expect(action == .fileCopied(source, destination)) + } + + @Test(arguments: [FileServiceError.itemAlreadyExists, .itemEmptyData, .itemNotCopied]) + func copyItem(throws error: FileServiceError) async throws { + // GIVEN + let service = service(action: .error(error)) + + // WHEN + // THEN + await #expect(throws: error) { + try await service.copyFile(from: .someExistingFile, to: .someNewFile) + } + + #expect(spy.actions.isEmpty == true) + } + + @Test(arguments: [URL.someNewFolder, .someNewFile]) + func createFolder(with location: URL) async throws { + // GIVEN + let service = service(action: .createFolder(location)) + + // WHEN + try await service.createFolder(at: location) + + // THEN + #expect(spy.actions.count == 1) + + let action = try #require(spy.actions.last) + + #expect(action == .folderCreated(location)) + } + + @Test(arguments: zip([URL.someExistingFolder, .someExistingFile, .someRandomURL], + [FileServiceError.itemAlreadyExists, .itemAlreadyExists, .itemNotFileURL])) + func createFolder( + with location: URL, + throws error: FileServiceError + ) async throws { + // GIVEN + let service = service(action: .error(error)) + + // WHEN + // THEN + await #expect(throws: error) { + try await service.createFolder(at: location) + } + + #expect(spy.actions.isEmpty == true) + } + + @Test(arguments: [URL.someNewFolder, .someNewFile]) + func deleteItem(with location: URL) async throws { + // GIVEN + let service = service(action: .deleteItem(location)) + + // WHEN + try await service.deleteItem(at: location) + + // THEN + #expect(spy.actions.count == 1) + + let action = try #require(spy.actions.last) + + #expect(action == .itemDeleted(location)) + } + + @Test(arguments: zip([URL.someNewFolder, .someNewFile, .someRandomURL], + [FileServiceError.itemNotExists, .itemNotExists, .itemNotFileURL])) + func deleteItem( + with location: URL, + throws error: FileServiceError + ) async throws { + // GIVEN + let service = service(action: .error(error)) + + // WHEN + // THEN + await #expect(throws: error) { + try await service.deleteItem(at: location) + } + + #expect(spy.actions.isEmpty == true) + } + + @Test(arguments: zip([URL.someExistingFolder, .someExistingFile, .someNewFolder, .someNewFile], + [true, true, false, false])) + func isItemExists( + with location: URL, + expects outcome: Bool + ) async throws { + // GIVEN + let service = service(action: .isItemExists(location, outcome)) + + // WHEN + let result = try await service.isItemExists(at: location) + + // THEN + #expect(result == outcome) + + let action = try #require(spy.actions.last) + + #expect(action == .itemExists(location)) + } + + @Test(arguments: zip([URL.someRandomURL], [FileServiceError.itemNotFileURL])) + func isItemExists( + with location: URL, + throws error: FileServiceError + ) async throws { + // GIVEN + let service = service(action: .error(error)) + + // WHEN + // THEN + await #expect(throws: error) { + try await service.isItemExists(at: location) + } + + #expect(spy.actions.isEmpty == true) + } + +} + +// MARK: - Helpers + +private extension FileServiceTests { + + // MARK: Functions + + func service(action: FileServiceMock.Action) -> FileServiceMock { + .init( + currentFolder: .someCurrentFolder, + action: action, + spy: spy + ) + } + +} diff --git a/Test/Sources/Cases/Public/Tasks/CopyFilesTaskTests.swift b/Test/Sources/Cases/Public/Tasks/CopyFilesTaskTests.swift new file mode 100644 index 0000000..dad2ad0 --- /dev/null +++ b/Test/Sources/Cases/Public/Tasks/CopyFilesTaskTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing + +@testable import ColibriLibrary + +struct CopyFilesTaskTests { + + // MARK: Properties + + private let resourceFolder = URL.someExistingFolder + private let rootFolder = URL.someNewFolder + + private let spy = FileServiceSpy() + + // MARK: Functions tests + + @Test func copyFiles() async throws { + // GIVEN + let files = files(of: File.allCases) + let actions = files.map { FileServiceMock.Action.copyFile($0.source, $0.destination) } + + let copyFiles = task(actions: actions) + + // WHEN + try await copyFiles(to: rootFolder) + + // THEN + #expect(spy.actions.count == actions.count) + + files.enumerated().forEach { index, file in + #expect(spy.actions[index] == .fileCopied(file.source, file.destination)) + } + } + + @Test(arguments: [FileServiceError.itemAlreadyExists, .itemEmptyData, .itemNotCopied]) + func copyFiles(throws error: FileServiceError) async throws { + // GIVEN + let files = files(of: Array(File.allCases[0...2])) + let actions = files.map { FileServiceMock.Action.copyFile($0.source, $0.destination) } + + let copyFiles = task(actions: actions + [.error(error)]) + + // WHEN + // THEN + await #expect(throws: error) { + try await copyFiles(to: rootFolder) + } + + #expect(spy.actions.count == actions.count) + + files.enumerated().forEach { index, file in + #expect(spy.actions[index] == .fileCopied(file.source, file.destination)) + } + } + +} + +// MARK: - Helpers + +private extension CopyFilesTaskTests { + + // MARK: Type aliases + + typealias FileURL = (source: URL, destination: URL) + + // MARK: Functions + + func files(of resourceFiles: [File]) -> [FileURL] { + resourceFiles.map { (resourceFolder.appendingPath($0.rawValue), rootFolder.appendingPath($0.fileName)) } + } + + func task(actions: [FileServiceMock.Action]) -> CopyFilesTask { + .init(fileService: FileServiceMock( + currentFolder: .someCurrentFolder, + actions: actions, + spy: spy + )) + } + +} diff --git a/Test/Sources/Cases/Public/Tasks/CreateFoldersTaskTests.swift b/Test/Sources/Cases/Public/Tasks/CreateFoldersTaskTests.swift new file mode 100644 index 0000000..af361a4 --- /dev/null +++ b/Test/Sources/Cases/Public/Tasks/CreateFoldersTaskTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing + +@testable import ColibriLibrary + +struct CreateFoldersTaskTests { + + // MARK: Properties + + private let spy = FileServiceSpy() + + // MARK: Functions tests + + @Test(arguments: [URL.someCurrentFolder, .someDotFolder, .someTildeFolder]) + func createFolders(with rootFolder: URL) async throws { + // GIVEN + let folders = Folder.allCases.map { rootFolder.appendingPath($0.path) } + let actions = folders.map { FileServiceMock.Action.createFolder($0) } + + let createFolders = task(actions: actions) + + // WHEN + try await createFolders(at: rootFolder) + + // THEN + #expect(spy.actions.count == actions.count) + + for index in actions.indices { + #expect(spy.actions[index] == .folderCreated(folders[index])) + } + } + +} + +// MARK: - Helpers + +private extension CreateFoldersTaskTests { + + // MARK: Functions + + func task(actions: [FileServiceMock.Action]) -> CreateFoldersTask { + .init(fileService: FileServiceMock( + currentFolder: .someCurrentFolder, + actions: actions, + spy: spy + )) + } + +} diff --git a/Tests/Library/Cases/Tasks/CreateRootFolderTaskTests.swift b/Test/Sources/Cases/Public/Tasks/CreateRootFolderTaskTests.swift similarity index 77% rename from Tests/Library/Cases/Tasks/CreateRootFolderTaskTests.swift rename to Test/Sources/Cases/Public/Tasks/CreateRootFolderTaskTests.swift index a3f6795..2ba499d 100644 --- a/Tests/Library/Cases/Tasks/CreateRootFolderTaskTests.swift +++ b/Test/Sources/Cases/Public/Tasks/CreateRootFolderTaskTests.swift @@ -1,7 +1,8 @@ -import ColibriLibrary import Foundation import Testing +@testable import ColibriLibrary + struct CreateRootFolderTaskTests { // MARK: Functions tests @@ -19,12 +20,10 @@ struct CreateRootFolderTaskTests { default: nil } - let fileService = FileServiceMock( + let task = CreateRootFolderTask(fileService: FileServiceMock( currentFolder: .someCurrentFolder, action: .createFolder(folder) - ) - - let task = CreateRootFolderTask(fileService: fileService) + )) // WHEN let result = try await task(name: name, @@ -35,18 +34,16 @@ struct CreateRootFolderTaskTests { #expect(result.isFileURL == true) } - @Test(arguments: [String.someProjectName], [FileServiceError.urlAlreadyExists]) + @Test(arguments: [String.someProjectName], [FileServiceError.itemAlreadyExists]) func task( name: String, throws error: FileServiceError ) async throws { // GIVEN - let fileService = FileServiceMock( + let task = CreateRootFolderTask(fileService: FileServiceMock( currentFolder: .someCurrentFolder, action: .error(error) - ) - - let task = CreateRootFolderTask(fileService: fileService) + )) // WHEN // THEN @@ -61,9 +58,9 @@ struct CreateRootFolderTaskTests { throws error: CreateRootFolderError ) async throws { // GIVEN - let fileService = FileServiceMock(currentFolder: .someCurrentFolder) - - let task = CreateRootFolderTask(fileService: fileService) + let task = CreateRootFolderTask(fileService: FileServiceMock( + currentFolder: .someCurrentFolder + )) // WHEN // THEN @@ -84,8 +81,8 @@ private extension String { // MARK: - URL+Constants private extension URL { - static let someCurrentProjectFolder = URL.someCurrentFolder.appending(component: String.someProjectName) - static let someDotProjectFolder = URL.someDotFolder.appending(component: String.someProjectName) - static let someNewProjectFolder = URL.someNewFolder.appending(component: String.someProjectName) - static let someTildeProjectFolder = URL.someTildeFolder.appending(component: String.someProjectName) + static let someCurrentProjectFolder = URL.someCurrentFolder.appendingPath(.someProjectName) + static let someDotProjectFolder = URL.someDotFolder.appendingPath(.someProjectName) + static let someNewProjectFolder = URL.someNewFolder.appendingPath(.someProjectName) + static let someTildeProjectFolder = URL.someTildeFolder.appendingPath(.someProjectName) } diff --git a/Tests/Library/Helpers/Extensions/URL+Samples.swift b/Test/Sources/Helpers/Extensions/URL+Samples.swift similarity index 100% rename from Tests/Library/Helpers/Extensions/URL+Samples.swift rename to Test/Sources/Helpers/Extensions/URL+Samples.swift diff --git a/Test/Sources/Helpers/Mocks/FileServiceMock.swift b/Test/Sources/Helpers/Mocks/FileServiceMock.swift new file mode 100644 index 0000000..99bd117 --- /dev/null +++ b/Test/Sources/Helpers/Mocks/FileServiceMock.swift @@ -0,0 +1,135 @@ +import ColibriLibrary +import Foundation + +final class FileServiceMock { + + // MARK: Properties + + private let folder: URL + + private var actions: [Action] = [] + + private weak var spy: FileServiceSpy? + + // MARK: Initialisers + + init( + currentFolder: URL, + action: Action? = nil, + spy: FileServiceSpy? = nil + ) { + self.actions = if let action { + [action] + } else { + [] + } + self.folder = currentFolder + self.spy = spy + } + + init( + currentFolder: URL, + actions: [Action], + spy: FileServiceSpy? = nil + ) { + self.actions = actions + self.folder = currentFolder + self.spy = spy + } + +} + +// MARK: - FileServicing + +extension FileServiceMock: FileServicing { + + // MARK: Computed + + var currentFolder: URL { + get async { folder } + } + + // MARK: Functions + + func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) { + guard let nextAction else { return } + + switch nextAction { + case .error(let error): + throw error + case let .copyFile(source, destination): + try await spy?.copyFile(from: source, to: destination) + default: + break + } + } + + func createFolder(at location: URL) async throws (FileServiceError) { + guard let nextAction else { return } + + switch nextAction { + case .error(let error): + throw error + case let .createFolder(location): + try await spy?.createFolder(at: location) + default: + break + } + } + + func deleteItem(at location: URL) async throws (FileServiceError) { + guard let nextAction else { return } + + switch nextAction { + case .error(let error): + throw error + case let .deleteItem(location): + try await spy?.deleteItem(at: location) + default: + break + } + } + + func isItemExists(at location: URL) async throws (FileServiceError) -> Bool { + guard let nextAction else { return false } + + switch nextAction { + case .error(let error): + throw error + case let .isItemExists(location, exists): + try await spy?.isItemExists(at: location) + return exists + default: + return false + } + } + +} + +// MARK: - Helpers + +private extension FileServiceMock { + + // MARK: Computed + + var nextAction: Action? { + guard !actions.isEmpty else { + return nil + } + + return actions.removeFirst() + } + +} + +// MARK: - Actions + +extension FileServiceMock { + enum Action { + case copyFile(URL, URL) + case createFolder(URL) + case deleteItem(URL) + case error(FileServiceError) + case isItemExists(URL, Bool) + } +} diff --git a/Test/Sources/Helpers/Spies/FileServiceSpy.swift b/Test/Sources/Helpers/Spies/FileServiceSpy.swift new file mode 100644 index 0000000..34ccd63 --- /dev/null +++ b/Test/Sources/Helpers/Spies/FileServiceSpy.swift @@ -0,0 +1,51 @@ +import Foundation + +@testable import ColibriLibrary + +final class FileServiceSpy { + + // MARK: Properties + + private(set) var actions: [Action] = [] + +} + +// MARK: - FileServicing + +extension FileServiceSpy: FileServicing { + + var currentFolder: URL { + get async { .someCurrentFolder } + } + + func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) { + actions.append(.fileCopied(source, destination)) + } + + func createFolder(at location: URL) async throws (FileServiceError) { + actions.append(.folderCreated(location)) + } + + func deleteItem(at location: URL) async throws (FileServiceError) { + actions.append(.itemDeleted(location)) + } + + @discardableResult + func isItemExists(at location: URL) async throws (FileServiceError) -> Bool { + actions.append(.itemExists(location)) + + return .random() + } + +} + +// MARK: - Action + +extension FileServiceSpy { + enum Action: Equatable { + case fileCopied(_ source: URL, _ destination: URL) + case folderCreated(_ location: URL) + case itemDeleted(_ location: URL) + case itemExists(_ location: URL) + } +} diff --git a/Tests/Library/Cases/Services/FileServiceTests.swift b/Tests/Library/Cases/Services/FileServiceTests.swift deleted file mode 100644 index f686fe2..0000000 --- a/Tests/Library/Cases/Services/FileServiceTests.swift +++ /dev/null @@ -1,154 +0,0 @@ -import ColibriLibrary -import Foundation -import Testing - -struct FileServiceTests { - - // MARK: Properties - - private let spy = FileServiceSpy() - - // MARK: Properties tests - - @Test func currentFolder() async { - // GIVEN - let url: URL = .someCurrentFolder - - let service = FileServiceMock(currentFolder: url) - - // WHEN - let folder = await service.currentFolder - - // THEN - #expect(folder == url) - #expect(folder.isFileURL == true) - } - - // MARK: Functions - - @Test(arguments: [URL.someNewFolder, .someNewFile]) - func createFolder(with url: URL) async throws { - // GIVEN - let service = FileServiceMock( - currentFolder: .someCurrentFolder, - action: .createFolder(url), - spy: spy - ) - - // WHEN - try await service.createFolder(at: url) - - // THEN - #expect(spy.isCreateFolderCalled == true) - #expect(spy.urlCalled == url) - } - - @Test(arguments: zip([URL.someExistingFolder, .someExistingFile, .someRandomURL], - [FileServiceError.urlAlreadyExists, .urlAlreadyExists, .urlNotFileURL])) - func createFolder( - with url: URL, - throws error: FileServiceError - ) async throws { - // GIVEN - let service = FileServiceMock( - currentFolder: .someCurrentFolder, - action: .error(error), - spy: spy - ) - - // WHEN - // THEN - await #expect(throws: error) { - try await service.createFolder(at: url) - } - - #expect(spy.isCreateFolderCalled == false) - #expect(spy.urlCalled == nil) - } - - @Test(arguments: [URL.someNewFolder, .someNewFile]) - func delete(with url: URL) async throws { - // GIVEN - let service = FileServiceMock( - currentFolder: .someCurrentFolder, - action: .delete(url), - spy: spy - ) - - // WHEN - try await service.delete(at: url) - - // THEN - #expect(spy.isDeleteCalled == true) - #expect(spy.urlCalled == url) - } - - @Test(arguments: zip([URL.someNewFolder, .someNewFile, .someRandomURL], - [FileServiceError.urlNotExists, .urlNotExists, .urlNotFileURL])) - func delete( - with url: URL, - throws error: FileServiceError - ) async throws { - // GIVEN - let service = FileServiceMock( - currentFolder: .someCurrentFolder, - action: .error(error), - spy: spy - ) - - // WHEN - // THEN - await #expect(throws: error) { - try await service.delete(at: url) - } - - #expect(spy.isDeleteCalled == false) - #expect(spy.urlCalled == nil) - } - - @Test(arguments: zip([URL.someExistingFolder, .someExistingFile, .someNewFolder, .someNewFile], - [true, true, false, false])) - func exists( - with url: URL, - expects outcome: Bool - ) async throws { - // GIVEN - let service = FileServiceMock( - currentFolder: .someCurrentFolder, - action: .exists(url, outcome), - spy: spy - ) - - // WHEN - let result = try await service.exists(at: url) - - // THEN - #expect(result == outcome) - - #expect(spy.isExistsAtCalled == true) - #expect(spy.urlCalled == url) - } - - @Test(arguments: zip([URL.someRandomURL], [FileServiceError.urlNotFileURL])) - func exists( - with url: URL, - throws error: FileServiceError - ) async throws { - // GIVEN - let service = FileServiceMock( - currentFolder: .someCurrentFolder, - action: .error(error), - spy: spy - ) - - // WHEN - // THEN - await #expect(throws: error) { - try await service.exists(at: url) - } - - #expect(spy.isExistsAtCalled == false) - #expect(spy.urlCalled == nil) - } - -} diff --git a/Tests/Library/ColibriLibraryTests.swift b/Tests/Library/ColibriLibraryTests.swift deleted file mode 100644 index 5cfd2da..0000000 --- a/Tests/Library/ColibriLibraryTests.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Testing - -struct ColibriLibraryTests {} diff --git a/Tests/Library/Helpers/Mocks/FileServiceMock.swift b/Tests/Library/Helpers/Mocks/FileServiceMock.swift deleted file mode 100644 index fa7b6d7..0000000 --- a/Tests/Library/Helpers/Mocks/FileServiceMock.swift +++ /dev/null @@ -1,84 +0,0 @@ -import ColibriLibrary -import Foundation - -struct FileServiceMock { - - // MARK: Properties - - private let action: Action? - private let folder: URL - - private weak var spy: FileServiceSpy? - - // MARK: Initialisers - - init( - currentFolder: URL, - action: Action? = nil, - spy: FileServiceSpy? = nil - ) { - self.action = action - self.folder = currentFolder - self.spy = spy - } - -} - -// MARK: - FileServicing - -extension FileServiceMock: FileServicing { - - // MARK: Computed - - var currentFolder: URL { - get async { folder } - } - - // MARK: Functions - - func createFolder(at url: URL) async throws(FileServiceError) { - switch action { - case .error(let error): - throw error - case let .createFolder(url): - try await spy?.createFolder(at: url) - default: - break - } - } - - func delete(at url: URL) async throws(FileServiceError) { - switch action { - case .error(let error): - throw error - case let .delete(url): - try await spy?.delete(at: url) - default: - break - } - } - - func exists(at url: URL) async throws(FileServiceError) -> Bool { - switch action { - case .error(let error): - throw error - case let .exists(url, exists): - try await spy?.exists(at: url) - return exists - default: - return false - } - } - -} - -// MARK: - Enumerations - -extension FileServiceMock { - enum Action { - case createFolder(URL) - case delete(URL) - case error(FileServiceError) - case exists(URL, Bool) - } -} diff --git a/Tests/Library/Helpers/Spies/FileServiceSpy.swift b/Tests/Library/Helpers/Spies/FileServiceSpy.swift deleted file mode 100644 index 6422bc9..0000000 --- a/Tests/Library/Helpers/Spies/FileServiceSpy.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -@testable import ColibriLibrary - -final class FileServiceSpy { - - // MARK: Properties - - private(set) var isCreateFolderCalled: Bool = false - private(set) var isDeleteCalled: Bool = false - private(set) var isExistsAtCalled: Bool = false - private(set) var urlCalled: URL? - -} - -// MARK: - FileServicing - -extension FileServiceSpy: FileServicing { - var currentFolder: URL { - get async { .someCurrentFolder } - } - - func createFolder(at url: URL) async throws(FileServiceError) { - isCreateFolderCalled = true - urlCalled = url - } - - func delete(at url: URL) async throws(FileServiceError) { - isDeleteCalled = true - urlCalled = url - } - - @discardableResult - func exists(at url: URL) async throws(FileServiceError) -> Bool { - isExistsAtCalled = true - urlCalled = url - - return .random() - } - -}