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: #3 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
parent
b8c354e614
commit
9be8fa4a31
@ -1,5 +1,4 @@
|
||||
import ArgumentParser
|
||||
import ColibriLibrary
|
||||
|
||||
@main
|
||||
struct Colibri: AsyncParsableCommand {
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
22
Executable/Sources/Options/CreateOptions.swift
Normal file
22
Executable/Sources/Options/CreateOptions.swift
Normal file
@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
20
Library/Resources/Files/Sources/App/App
Normal file
20
Library/Resources/Files/Sources/App/App
Normal file
@ -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()
|
||||
}
|
||||
|
||||
}
|
20
Library/Resources/Files/Sources/App/AppOptions
Normal file
20
Library/Resources/Files/Sources/App/AppOptions
Normal file
@ -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?
|
||||
|
||||
}
|
||||
}
|
87
Library/Resources/Files/Sources/DockerFile
Normal file
87
Library/Resources/Files/Sources/DockerFile
Normal file
@ -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"]
|
2
Library/Resources/Files/Sources/DockerIgnore
Normal file
2
Library/Resources/Files/Sources/DockerIgnore
Normal file
@ -0,0 +1,2 @@
|
||||
.build
|
||||
.git
|
10
Library/Resources/Files/Sources/GitIgnore
Normal file
10
Library/Resources/Files/Sources/GitIgnore
Normal file
@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/.devContainer
|
||||
/.swiftpm
|
||||
/.vscode
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
.env.*
|
||||
.env
|
11
Library/Resources/Files/Sources/Library/AppArguments
Normal file
11
Library/Resources/Files/Sources/Library/AppArguments
Normal file
@ -0,0 +1,11 @@
|
||||
import Logging
|
||||
|
||||
public protocol AppArguments {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var hostname: String { get }
|
||||
var logLevel: Logger.Level? { get }
|
||||
var port: Int { get }
|
||||
|
||||
}
|
69
Library/Resources/Files/Sources/Library/AppBuilder
Normal file
69
Library/Resources/Files/Sources/Library/AppBuilder
Normal file
@ -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<AppRequestContext> {
|
||||
let router = Router()
|
||||
|
||||
router.add(middleware: LogRequestsMiddleware(logger.logLevel))
|
||||
|
||||
router.get("/") { _,_ in
|
||||
""
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
}
|
11
Library/Resources/Files/Sources/Library/Environment
Normal file
11
Library/Resources/Files/Sources/Library/Environment
Normal file
@ -0,0 +1,11 @@
|
||||
import Hummingbird
|
||||
|
||||
extension Environment {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
public var logLevel: String? {
|
||||
self.get("LOG_LEVEL")
|
||||
}
|
||||
|
||||
}
|
9
Library/Resources/Files/Sources/Library/LoggerLevel
Normal file
9
Library/Resources/Files/Sources/Library/LoggerLevel
Normal file
@ -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
|
201
Library/Resources/Files/Sources/License
Normal file
201
Library/Resources/Files/Sources/License
Normal file
@ -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.
|
45
Library/Resources/Files/Sources/Package
Normal file
45
Library/Resources/Files/Sources/Package
Normal file
@ -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"
|
||||
)
|
||||
]
|
||||
)
|
10
Library/Resources/Files/Sources/Readme
Normal file
10
Library/Resources/Files/Sources/Readme
Normal file
@ -0,0 +1,10 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/hummingbird-project/hummingbird/assets/9382567/48de534f-8301-44bd-b117-dfb614909efd">
|
||||
<img src="https://github.com/hummingbird-project/hummingbird/assets/9382567/e371ead8-7ca1-43e3-8077-61d8b5eab879">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
# Hummingbird project template
|
||||
|
||||
This is a template for your new [Hummingbird](https://wwww.hummingbird.codes) project.
|
33
Library/Resources/Files/Sources/Test/AppTests
Normal file
33
Library/Resources/Files/Sources/Test/AppTests
Normal file
@ -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: "")
|
||||
}
|
12
Library/Resources/Files/Sources/Test/TestArguments
Normal file
12
Library/Resources/Files/Sources/Test/TestArguments
Normal file
@ -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
|
||||
|
||||
}
|
74
Library/Sources/Internal/Enumerations/File.swift
Normal file
74
Library/Sources/Internal/Enumerations/File.swift
Normal file
@ -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 {}
|
48
Library/Sources/Internal/Enumerations/Folder.swift
Normal file
48
Library/Sources/Internal/Enumerations/Folder.swift
Normal file
@ -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 }
|
||||
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Bundleable
|
||||
|
||||
extension Bundle: Bundleable {}
|
72
Library/Sources/Internal/Extensions/Pipe+Properties.swift
Normal file
72
Library/Sources/Internal/Extensions/Pipe+Properties.swift
Normal file
@ -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<Data>.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()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Processable
|
||||
|
||||
extension Process: Processable {}
|
35
Library/Sources/Internal/Extensions/URL+Extensions.swift
Normal file
35
Library/Sources/Internal/Extensions/URL+Extensions.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
17
Library/Sources/Internal/Protocols/Processable.swift
Normal file
17
Library/Sources/Internal/Protocols/Processable.swift
Normal file
@ -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
|
||||
|
||||
}
|
75
Library/Sources/Internal/Tasks/RunProcessTask.swift
Normal file
75
Library/Sources/Internal/Tasks/RunProcessTask.swift
Normal file
@ -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
|
||||
}
|
9
Library/Sources/Public/Protocols/Bundleable.swift
Normal file
9
Library/Sources/Public/Protocols/Bundleable.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
public protocol Bundleable {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func url(forResource name: String?, withExtension ext: String?, subdirectory subpath: String?) -> URL?
|
||||
|
||||
}
|
28
Library/Sources/Public/Protocols/FileServicing.swift
Normal file
28
Library/Sources/Public/Protocols/FileServicing.swift
Normal file
@ -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
|
||||
}
|
79
Library/Sources/Public/Services/FileService.swift
Normal file
79
Library/Sources/Public/Services/FileService.swift
Normal file
@ -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)
|
||||
}
|
||||
|
||||
}
|
39
Library/Sources/Public/Tasks/CopyFilesTask.swift
Normal file
39
Library/Sources/Public/Tasks/CopyFilesTask.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
25
Library/Sources/Public/Tasks/CreateFoldersTask.swift
Normal file
25
Library/Sources/Public/Tasks/CreateFoldersTask.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
|
24
Library/Sources/Public/Tasks/InitGitInFolderTask.swift
Normal file
24
Library/Sources/Public/Tasks/InitGitInFolderTask.swift
Normal file
@ -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"])
|
||||
}
|
||||
|
||||
}
|
@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
@ -1 +0,0 @@
|
||||
import Foundation
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
125
Test/Sources/Cases/Internal/Enumerations/FileTests.swift
Normal file
125
Test/Sources/Cases/Internal/Enumerations/FileTests.swift
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
35
Test/Sources/Cases/Internal/Enumerations/FolderTests.swift
Normal file
35
Test/Sources/Cases/Internal/Enumerations/FolderTests.swift
Normal file
@ -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/"
|
||||
]
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
68
Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift
Normal file
68
Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift
Normal file
@ -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")
|
||||
]
|
||||
}
|
||||
}
|
181
Test/Sources/Cases/Public/Services/FileServiceTests.swift
Normal file
181
Test/Sources/Cases/Public/Services/FileServiceTests.swift
Normal file
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
80
Test/Sources/Cases/Public/Tasks/CopyFilesTaskTests.swift
Normal file
80
Test/Sources/Cases/Public/Tasks/CopyFilesTaskTests.swift
Normal file
@ -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
|
||||
))
|
||||
}
|
||||
|
||||
}
|
49
Test/Sources/Cases/Public/Tasks/CreateFoldersTaskTests.swift
Normal file
49
Test/Sources/Cases/Public/Tasks/CreateFoldersTaskTests.swift
Normal file
@ -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
|
||||
))
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
135
Test/Sources/Helpers/Mocks/FileServiceMock.swift
Normal file
135
Test/Sources/Helpers/Mocks/FileServiceMock.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
51
Test/Sources/Helpers/Spies/FileServiceSpy.swift
Normal file
51
Test/Sources/Helpers/Spies/FileServiceSpy.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
import Testing
|
||||
|
||||
struct ColibriLibraryTests {}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user