From 78b0685ff956af62ee6c2eb20ff3751c14e16d13 Mon Sep 17 00:00:00 2001 From: Vyacheslav Khorkov Date: Sat, 12 Aug 2023 13:35:23 +0500 Subject: [PATCH] Initial commit --- .github/scripts/checkSpell.sh | 13 + .github/scripts/spell_checker_whitelist.yml | 98 ++++++++ .github/workflows/regress.yml | 23 ++ .gitignore | 10 + .spi.yml | 4 + .swiftlint.yml | 112 +++++++++ .../xcshareddata/IDETemplateMacros.plist | 14 ++ LICENSE | 21 ++ Makefile | 15 ++ Package.swift | 25 ++ README.md | 43 ++++ Sources/Fish.swift | 12 + .../Extensions/URL+Extensions.swift | 36 +++ .../Manager/FilesManager+IFileManager.swift | 57 +++++ .../Manager/FilesManager+IFolderManager.swift | 59 +++++ .../Manager/FilesManager+IItemManager.swift | 37 +++ .../Implementation/Manager/FilesManager.swift | 68 ++++++ .../Implementation/Models/FileStorage.swift | 66 ++++++ .../Implementation/Models/FolderStorage.swift | 83 +++++++ .../Extensions/String+RelativePath.swift | 23 ++ Sources/Public/FishError.swift | 42 ++++ Sources/Public/General/File.swift | 50 ++++ Sources/Public/General/Folder.swift | 60 +++++ Sources/Public/Interfaces/IFile.swift | 28 +++ Sources/Public/Interfaces/IFilesManager.swift | 147 ++++++++++++ Sources/Public/Interfaces/IFolder.swift | 77 ++++++ Sources/Public/Interfaces/IItem.swift | 56 +++++ Tests/FileTests.swift | 112 +++++++++ Tests/FolderTests.swift | 140 +++++++++++ Tests/IFileTests.swift | 222 ++++++++++++++++++ Tests/IFolderTests.swift | 208 ++++++++++++++++ Tests/StringTests.swift | 19 ++ Tests/Utils/String+Fake.swift | 15 ++ 33 files changed, 1995 insertions(+) create mode 100755 .github/scripts/checkSpell.sh create mode 100644 .github/scripts/spell_checker_whitelist.yml create mode 100644 .github/workflows/regress.yml create mode 100644 .gitignore create mode 100644 .spi.yml create mode 100644 .swiftlint.yml create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/Fish.swift create mode 100644 Sources/Implementation/Extensions/URL+Extensions.swift create mode 100644 Sources/Implementation/Manager/FilesManager+IFileManager.swift create mode 100644 Sources/Implementation/Manager/FilesManager+IFolderManager.swift create mode 100644 Sources/Implementation/Manager/FilesManager+IItemManager.swift create mode 100644 Sources/Implementation/Manager/FilesManager.swift create mode 100644 Sources/Implementation/Models/FileStorage.swift create mode 100644 Sources/Implementation/Models/FolderStorage.swift create mode 100644 Sources/Public/Extensions/String+RelativePath.swift create mode 100644 Sources/Public/FishError.swift create mode 100644 Sources/Public/General/File.swift create mode 100644 Sources/Public/General/Folder.swift create mode 100644 Sources/Public/Interfaces/IFile.swift create mode 100644 Sources/Public/Interfaces/IFilesManager.swift create mode 100644 Sources/Public/Interfaces/IFolder.swift create mode 100644 Sources/Public/Interfaces/IItem.swift create mode 100644 Tests/FileTests.swift create mode 100644 Tests/FolderTests.swift create mode 100644 Tests/IFileTests.swift create mode 100644 Tests/IFolderTests.swift create mode 100644 Tests/StringTests.swift create mode 100644 Tests/Utils/String+Fake.swift diff --git a/.github/scripts/checkSpell.sh b/.github/scripts/checkSpell.sh new file mode 100755 index 0000000..ee9719c --- /dev/null +++ b/.github/scripts/checkSpell.sh @@ -0,0 +1,13 @@ +# It's a temporary solution to check the spelling of *.md and Sources/*.swift files +# Need to install https://github.com/fromkk/SpellChecker + +docs_output=`find . -type f -name '*.md' -exec ~/.mint/bin/SpellChecker --yml .github/scripts/spell_checker_whitelist.yml -- {} \; | grep -v 'no typo'` +if [[ $docs_output ]]; then echo $docs_output; fi + +source_output=`find Sources -type f -name '*.swift' -exec ~/.mint/bin/SpellChecker --yml .github/scripts/spell_checker_whitelist.yml -- {} \; | grep -v 'no typo'` +if [[ $source_output ]]; then echo $source_output; fi + +tests_output=`find Tests -type f -name '*.swift' -exec ~/.mint/bin/SpellChecker --yml .github/scripts/spell_checker_whitelist.yml -- {} \; | grep -v 'no typo'` +if [[ $tests_output ]]; then echo $tests_output; fi + +if [[ $docs_output || $source_output || $tests_output ]]; then exit 1; fi diff --git a/.github/scripts/spell_checker_whitelist.yml b/.github/scripts/spell_checker_whitelist.yml new file mode 100644 index 0000000..faf3835 --- /dev/null +++ b/.github/scripts/spell_checker_whitelist.yml @@ -0,0 +1,98 @@ +whiteList: + - pbxproj + - vyacheslav + - subspecs + - xcscheme + - usleep + - xcbeautify + - github + - thii + - associatedtype + - rethrows + - recursively + - appex + - xcschemes + - xcuserdatad + - xcuserdata + - xcshareddata + - lhs + - stderror + - anyarray + - localized + - kareman + - autoreleasepool + - tput + - boolable + - nsrange + - xcodebuild + - sdk + - podfile + - xcodeproj + - yml + - swiftlint + - ykkd + - iphoneos + - ios + - iphonesimulator + - codable + - xcworkspace + - sysctl + - machdep + - dirs + - xcconfig + - xcargs + - wholemodule + - couldn + - xcframeworks + - alamofire + - doesn + - xcodegen + - swiftyfinch + - serialization + - isystem + - githubusercontent + - img + - khorkov + - proj + - deintegrate + - rhs + - colton + - schlosser + - parallelization + - iframework + - fd + - ecf + - bcfeef + - mlch + - utsname + - uname + - cocoapods + - leavez + - amazonaws + - realized + - fbadge + - ftype + - dswift + - swifty + - swiftish + - fpackages + - fapi + - fswiftpackageindex + - swiftpackageindex + - didn + - optimizations + - clt + - nexport + - zshrc + - zshenv + - th + - finalized + - fswiftyfinch + - dplatforms + - svg + - yandex + - fff + - faq + - standardized + - shwifty + - utf diff --git a/.github/workflows/regress.yml b/.github/workflows/regress.yml new file mode 100644 index 0000000..61c1e7d --- /dev/null +++ b/.github/workflows/regress.yml @@ -0,0 +1,23 @@ +name: Regress + +on: [push, workflow_dispatch, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: norio-nomura/action-swiftlint@3.2.1 + with: { args: --strict } + + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - uses: maxim-lobanov/setup-xcode@v1 + with: { xcode-version: '14.2' } + - name: Testing + # https://github.com/actions/runner-images/issues/1665 + run: | + brew install xcbeautify + swift test | xcbeautify diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4db1cbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.build + +# Keep xcshareddata +.swiftpm/* +!.swiftpm/xcode +.swiftpm/xcode/* +!.swiftpm/xcode/package.xcworkspace +.swiftpm/xcode/package.xcworkspace/* +!.swiftpm/xcode/package.xcworkspace/xcshareddata diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..c1ab456 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [Fish] diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..2612aff --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,112 @@ +disabled_rules: + - let_var_whitespace + - conditional_returns_on_newline + - todo + - reduce_boolean + - attributes + +opt_in_rules: + - identifier_name + - attributes + - force_unwrapping + - sorted_imports + - block_based_kvo + - class_delegate_protocol + - closing_brace + - closure_parameter_position + - colon + - comma + - compiler_protocol_init + - control_statement + - custom_rules + - cyclomatic_complexity + - deployment_target + - discarded_notification_center_observer + - discouraged_direct_init + - duplicate_enum_cases + - duplicate_imports + - dynamic_inline + - empty_enum_arguments + - empty_parameters + - empty_parentheses_with_trailing_closure + - file_length + - for_where + - force_cast + - force_try + - function_body_length + - function_parameter_count + - generic_type_name + - implicit_getter + - is_disjoint + - large_tuple + - leading_whitespace + - legacy_cggeometry_functions + - legacy_constant + - legacy_constructor + - legacy_hashing + - legacy_nsgeometry_functions + - line_length + - mark + - multiple_closures_with_trailing_closure + - nesting + - no_fallthrough_only + - no_space_in_method_call + - notification_center_detachment + - nsobject_prefer_isequal + - opening_brace + - operator_whitespace + - orphaned_doc_comment + - private_over_fileprivate + - private_unit_test + - protocol_property_accessors_order + - redundant_discardable_let + - redundant_objc_attribute + - redundant_optional_initialization + - redundant_set_access_control + - redundant_string_enum_value + - redundant_void_return + - return_arrow_whitespace + - shorthand_operator + - statement_position + - superfluous_disable_command + - switch_case_alignment + - syntactic_sugar + - trailing_comma + - trailing_newline + - trailing_semicolon + - trailing_whitespace + - type_body_length + - type_name + - unneeded_break_in_switch + - unused_closure_parameter + - unused_control_flow_label + - unused_enumerated + - unused_optional_binding + - unused_setter_value + - valid_ibinspectable + - vertical_parameter_alignment + - vertical_whitespace + - void_return + - weak_delegate + - xctfail_message + - explicit_init + - file_header + - missing_docs + +included: + - Sources + +# Custom +large_tuple: 4 +nesting: + type_level: 2 +function_parameter_count: 6 +file_header: + required_pattern: | + \/\/ + \/\/ .*?\.swift + \/\/ Fish + \/\/ + \/\/ Created by .*? on \d{1,2}\.\d{1,2}\.\d{4}\. + \/\/ Copyright © .*?\. All rights reserved\. + \/\/ diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 0000000..e5fc9b8 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,14 @@ + + + + + FILEHEADER + +// ___FILENAME___ +// Fish +// +// Created by ___FULLUSERNAME___ on ___DATE___. +// Copyright © ___YEAR___ Vyacheslav Khorkov. All rights reserved. +// + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..06304ec --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Vyacheslav Khorkov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fcc1cd3 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: debug +debug: lint + swift build --arch arm64 + +.PHONY: lint +lint: + swiftlint --strict --quiet + +.PHONY: test +test: + swift test | xcbeautify + +.PHONY: spelling +spelling: + .github/scripts/checkSpell.sh diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..04f525f --- /dev/null +++ b/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version:5.7 + +import PackageDescription + +let package = Package( + name: "fish", + platforms: [.macOS(.v12)], + products: [ + .library( + name: "Fish", + targets: ["Fish"] + ) + ], + targets: [ + .target( + name: "Fish", + path: "Sources" + ), + .testTarget( + name: "FishTests", + dependencies: ["Fish"], + path: "Tests" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..eea6b6e --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +
+

+ +
+ Fish +
+

+ +# Motivation + +I used the [Files](https://github.com/JohnSundell/Files) in [the first Rugby version](https://github.com/swiftyfinch/Rugby/blob/1.23.0/Package.swift#L15). +But this library has some drawbacks:\ +`-` There are some issues with files enumeration;\ +`-` It has limited testability;\ +`-` Now it looks like a public archive. The last request was merged in 2022. + +## Description + +`Fish` is a small library that was developed to solve the above problems.\ +It providing convenient wrappers for interacting with the file system.\ +Under the hood it uses [`FileManager`](https://developer.apple.com/documentation/foundation/filemanager) and other parts of [`Foundation`](https://developer.apple.com/documentation/foundation). + +This library was a part of 🏈 [Rugby 2.x](https://github.com/swiftyfinch/Rugby). + +
+ +# How to install 📦 + +Add it to the dependencies for your package. More info [here](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). +```swift +.package(url: "https://github.com/swiftyfinch/Fish", from: "0.1.0") +``` + +## How to use 🚀 + +```swift +let file = try Folder.current.createFile( + named: "example.txt", + contents: "Hello world!" +) +try file.append("You can find more info in docs.") +try file.delete() +``` diff --git a/Sources/Fish.swift b/Sources/Fish.swift new file mode 100644 index 0000000..ccf538e --- /dev/null +++ b/Sources/Fish.swift @@ -0,0 +1,12 @@ +// +// Fish.swift +// Fish +// +// Created by Vyacheslav Khorkov on 20.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +/// The files manager which is using in the implementations as the shared storage. +/// +/// It can be changed. For example, for testing. +public var sharedStorage: IFilesManager = FilesManager(fileManager: .default) diff --git a/Sources/Implementation/Extensions/URL+Extensions.swift b/Sources/Implementation/Extensions/URL+Extensions.swift new file mode 100644 index 0000000..75569e2 --- /dev/null +++ b/Sources/Implementation/Extensions/URL+Extensions.swift @@ -0,0 +1,36 @@ +// +// URL+Extensions.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +extension URL { + var name: String { lastPathComponent } + var nameExcludingExtension: String { deletingPathExtension().lastPathComponent } + var parent: URL { deletingLastPathComponent() } + + func relativePath(to relativePath: String) -> String { + path.relativePath(to: relativePath) + } + + func creationDate() throws -> Date { + guard let creationDate = try resourceValues(forKeys: [.creationDateKey]).creationDate else { + throw FishError.missingCreationDate(path: path) + } + return creationDate + } + + func fileSize() throws -> Int { + let keys: Set = [.fileSizeKey] + let resourceValues = try resourceValues(forKeys: keys) + let size = resourceValues.fileSize + guard let size else { + throw FishError.missingSize(path: path) + } + return size + } +} diff --git a/Sources/Implementation/Manager/FilesManager+IFileManager.swift b/Sources/Implementation/Manager/FilesManager+IFileManager.swift new file mode 100644 index 0000000..f8894bf --- /dev/null +++ b/Sources/Implementation/Manager/FilesManager+IFileManager.swift @@ -0,0 +1,57 @@ +// +// FilesManager+IFileManager.swift +// Fish +// +// Created by Vyacheslav Khorkov on 20.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +extension FilesManager: IFileManager { + func file(at path: String) throws -> IFile { + let fileURL = URL(fileURLWithPath: path) + guard isItemExist(at: fileURL.path) else { throw FishError.fileNotFound(fileURL.path) } + return file(at: fileURL) + } + + func files(at path: String, deep: Bool) throws -> [IFile] { + try enumerator(for: path, deep: deep).filesURLs().compactMap(file(at:)) + } + + @discardableResult + func createFile(at path: String, contents text: String?) throws -> IFile { + let data: Data? + if let text { + guard let textData = text.data(using: .utf8) else { throw FishError.damagedData } + data = textData + } else { + data = nil + } + fileManager.createFile(atPath: path, contents: data) + return try file(at: path) + } + + func append(_ text: String, to file: URL) throws { + guard let data = text.data(using: .utf8) else { throw FishError.damagedData } + let handle = try FileHandle(forWritingTo: file) + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } + + func write(_ text: String, to file: URL) throws { + guard let data = text.data(using: .utf8) else { throw FishError.damagedData } + try data.write(to: file) + } + + func read(file: URL) throws -> String { + let data = try readData(file: file) + guard let text = String(data: data, encoding: .utf8) else { throw FishError.damagedData } + return text + } + + func readData(file: URL) throws -> Data { + try Data(contentsOf: file) + } +} diff --git a/Sources/Implementation/Manager/FilesManager+IFolderManager.swift b/Sources/Implementation/Manager/FilesManager+IFolderManager.swift new file mode 100644 index 0000000..d9a8d8a --- /dev/null +++ b/Sources/Implementation/Manager/FilesManager+IFolderManager.swift @@ -0,0 +1,59 @@ +// +// FilesManager+IFolderManager.swift +// Fish +// +// Created by Vyacheslav Khorkov on 20.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +extension FilesManager: IFolderManager { + func currentFolder() throws -> IFolder { + try folder(at: fileManager.currentDirectoryPath) + } + + func homeFolder() throws -> IFolder { + folder(at: fileManager.homeDirectoryForCurrentUser) + } + + func isFolder(at path: String) -> Bool { + var isFolder: ObjCBool = false + guard fileManager.fileExists(atPath: path, isDirectory: &isFolder) else { return false } + return isFolder.boolValue + } + + func folder(at path: String) throws -> IFolder { + let folderURL = URL(fileURLWithPath: path) + guard isItemExist(at: folderURL.path) else { throw FishError.folderNotFound(folderURL.path) } + return folder(at: folderURL) + } + + func folders(at path: String, deep: Bool) throws -> [IFolder] { + try enumerator(for: path, deep: deep).folderURLs().compactMap(folder(at:)) + } + + @discardableResult + func createFolder(at path: String) throws -> IFolder { + if !fileManager.fileExists(atPath: path) { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true) + } + return try folder(at: path) + } + + func isFolderEmpty(at path: String) throws -> Bool { + try enumerator(for: path, deep: false).nextObject() == nil + } + + func emptyFolder(at path: String) throws { + try enumerator(for: path, deep: false).urls().forEach { + try deleteItem(at: $0.path) + } + } + + func folderSize(at path: String) throws -> Int { + try enumerator(for: path, deep: true, keys: [.fileSizeKey]).filesURLs().reduce(into: 0) { size, url in + try size += url.fileSize() + } + } +} diff --git a/Sources/Implementation/Manager/FilesManager+IItemManager.swift b/Sources/Implementation/Manager/FilesManager+IItemManager.swift new file mode 100644 index 0000000..e743b41 --- /dev/null +++ b/Sources/Implementation/Manager/FilesManager+IItemManager.swift @@ -0,0 +1,37 @@ +// +// FilesManager+IItemManager.swift +// Fish +// +// Created by Vyacheslav Khorkov on 20.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +extension FilesManager: IItemManager { + func isItemExist(at path: String) -> Bool { + fileManager.fileExists(atPath: path) + } + + func deleteItem(at path: String) throws { + try fileManager.removeItem(atPath: path) + } + + func moveItem(at itemURL: URL, to folderPath: String, replace: Bool) throws { + let folder = try createFolder(at: folderPath) + let destinationPath = folder.subpath(itemURL.name) + if replace { + try? deleteItem(at: destinationPath) + } + try fileManager.moveItem(atPath: itemURL.path, toPath: destinationPath) + } + + func copyItem(at itemURL: URL, to folderPath: String, replace: Bool) throws { + let folder = try createFolder(at: folderPath) + let destinationPath = folder.subpath(itemURL.name) + if replace { + try? deleteItem(at: destinationPath) + } + try fileManager.copyItem(atPath: itemURL.path, toPath: destinationPath) + } +} diff --git a/Sources/Implementation/Manager/FilesManager.swift b/Sources/Implementation/Manager/FilesManager.swift new file mode 100644 index 0000000..0206ee5 --- /dev/null +++ b/Sources/Implementation/Manager/FilesManager.swift @@ -0,0 +1,68 @@ +// +// FilesManager.swift +// Fish +// +// Created by Vyacheslav Khorkov on 17.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +final class FilesManager { + let fileManager: FileManager + + init(fileManager: FileManager) { + self.fileManager = fileManager + } +} + +extension FilesManager { + func file(at url: URL) -> IFile { + FileStorage(url, storage: self) + } + + func folder(at url: URL) -> IFolder { + FolderStorage(url, storage: self) + } +} + +extension FilesManager { + func enumerator(for path: String, + deep: Bool, + keys: [URLResourceKey]? = nil) throws -> FileManager.DirectoryEnumerator { + var options: FileManager.DirectoryEnumerationOptions = [.skipsHiddenFiles] + if !deep { options.formUnion([.skipsSubdirectoryDescendants, .skipsPackageDescendants]) } + var keys = keys ?? [] + if !keys.contains(.isRegularFileKey) { keys.append(.isRegularFileKey) } + guard let enumerator = fileManager.enumerator( + at: URL(fileURLWithPath: path), + includingPropertiesForKeys: keys, + options: options + ) else { throw FishError.failCreateEnumerator } + return enumerator + } +} + +extension FileManager.DirectoryEnumerator { + func urls() -> [URL] { + compactMap { ($0 as? URL)?.standardizedFileURL } + } + + func filesURLs() throws -> [URL] { + try compactMap { element in + guard let url = (element as? URL)?.standardizedFileURL else { return nil } + let resourceValues = try url.resourceValues(forKeys: [.isRegularFileKey]) + let isFile = (resourceValues.isRegularFile == true) + return isFile ? url : nil + } + } + + func folderURLs() throws -> [URL] { + try compactMap { element in + guard let url = (element as? URL)?.standardizedFileURL else { return nil } + let resourceValues = try url.resourceValues(forKeys: [.isRegularFileKey]) + let isDirectory = (resourceValues.isRegularFile != true) + return isDirectory ? url : nil + } + } +} diff --git a/Sources/Implementation/Models/FileStorage.swift b/Sources/Implementation/Models/FileStorage.swift new file mode 100644 index 0000000..aa0cb7b --- /dev/null +++ b/Sources/Implementation/Models/FileStorage.swift @@ -0,0 +1,66 @@ +// +// FileStorage.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +struct FileStorage { + private let file: URL + private let storage: IFilesManager + + init(_ fileURL: URL, storage: IFilesManager) { + precondition(!storage.isFolder(at: fileURL.path), "The path '\(fileURL.path)' isn't a file path.") + self.file = fileURL + self.storage = storage + } +} + +// MARK: - IItem + +extension FileStorage: IItem { + var path: String { file.path } + var pathExtension: String { file.pathExtension } + var name: String { file.name } + var nameExcludingExtension: String { file.nameExcludingExtension } + var parent: IFolder? { try? storage.folder(at: file.parent.path) } + + func relativePath(to folder: IFolder) -> String { file.relativePath(to: folder.path) } + func creationDate() throws -> Date { try file.creationDate() } + func size() throws -> Int { try file.fileSize() } + + func delete() throws { + try storage.deleteItem(at: file.path) + } + + func move(to folderPath: String, replace: Bool) throws { + try storage.moveItem(at: file, to: folderPath, replace: replace) + } + + func copy(to folderPath: String, replace: Bool) throws { + try storage.copyItem(at: file, to: folderPath, replace: replace) + } +} + +// MARK: - IFile + +extension FileStorage: IFile { + func append(_ text: String) throws { + try storage.append(text, to: file) + } + + func write(_ text: String) throws { + try storage.write(text, to: file) + } + + func read() throws -> String { + try storage.read(file: file) + } + + func readData() throws -> Data { + try storage.readData(file: file) + } +} diff --git a/Sources/Implementation/Models/FolderStorage.swift b/Sources/Implementation/Models/FolderStorage.swift new file mode 100644 index 0000000..849c317 --- /dev/null +++ b/Sources/Implementation/Models/FolderStorage.swift @@ -0,0 +1,83 @@ +// +// FolderStorage.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +struct FolderStorage { + private let folder: URL + private let storage: IFilesManager + + init(_ folderURL: URL, storage: IFilesManager) { + precondition(storage.isFolder(at: folderURL.path), "The path '\(folderURL.path)' isn't a folder path.") + self.folder = folderURL + self.storage = storage + } +} + +// MARK: - Item + +extension FolderStorage: IItem { + var path: String { folder.path } + var pathExtension: String { folder.pathExtension } + var name: String { folder.name } + var nameExcludingExtension: String { folder.nameExcludingExtension } + var parent: IFolder? { try? storage.folder(at: folder.parent.path) } + + func relativePath(to anotherFolder: IFolder) -> String { folder.relativePath(to: anotherFolder.path) } + func creationDate() throws -> Date { try folder.creationDate() } + func size() throws -> Int { try storage.folderSize(at: folder.path) } + + func delete() throws { + try storage.deleteItem(at: folder.path) + } + + func move(to folderPath: String, replace: Bool) throws { + try storage.moveItem(at: folder, to: folderPath, replace: replace) + } + + func copy(to folderPath: String, replace: Bool) throws { + try storage.copyItem(at: folder, to: folderPath, replace: replace) + } +} + +// MARK: - IFolder + +extension FolderStorage: IFolder { + func subpath(_ pathComponents: String...) -> String { + ([path] + pathComponents).joined(separator: "/") + } + + func file(named name: String) throws -> IFile { + try File.at(subpath(name)) + } + + func files(deep: Bool) throws -> [IFile] { + try storage.files(at: folder.path, deep: deep) + } + + func folders(deep: Bool) throws -> [IFolder] { + try storage.folders(at: folder.path, deep: deep) + } + + @discardableResult + public func createFile(named name: String, contents text: String?) throws -> IFile { + try storage.createFile(at: folder.appendingPathComponent(name).path, contents: text) + } + + func createFolder(named name: String) throws -> IFolder { + try storage.createFolder(at: folder.appendingPathComponent(name).path) + } + + func isEmpty() throws -> Bool { + try storage.isFolderEmpty(at: folder.path) + } + + func emptyFolder() throws { + try storage.emptyFolder(at: folder.path) + } +} diff --git a/Sources/Public/Extensions/String+RelativePath.swift b/Sources/Public/Extensions/String+RelativePath.swift new file mode 100644 index 0000000..265be57 --- /dev/null +++ b/Sources/Public/Extensions/String+RelativePath.swift @@ -0,0 +1,23 @@ +// +// String+RelativePath.swift +// Fish +// +// Created by Vyacheslav Khorkov on 21.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +public extension String { + /// Removes the prefix if it equals to the path. + /// + /// The path can contain `/` at the end. + /// + /// - Parameter path: The path with the common prefix to the string. + /// - Returns: A new string without the path prefix or the same string. + func relativePath(to path: String) -> String { + let path = path.hasSuffix("/") ? path : "\(path)/" + if hasPrefix(path) { + return String(dropFirst(path.count)) + } + return self + } +} diff --git a/Sources/Public/FishError.swift b/Sources/Public/FishError.swift new file mode 100644 index 0000000..00c66f1 --- /dev/null +++ b/Sources/Public/FishError.swift @@ -0,0 +1,42 @@ +// +// FishError.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +/// An enumeration of all error types in Fish library. +public enum FishError: LocalizedError, Equatable { + /// File not found at path. + case fileNotFound(String) + /// Folder not found at path. + case folderNotFound(String) + /// Failed to create files enumerator. + case failCreateEnumerator + /// Can't encoding text to data. + case damagedData + /// Missing creationDate of item at path. + case missingCreationDate(path: String) + /// Missing allocated size of item at path. + case missingSize(path: String) + + public var errorDescription: String? { + switch self { + case .fileNotFound(let path): + return "File not found at path '\(path)'." + case .folderNotFound(let path): + return "Folder not found at path '\(path)'." + case .failCreateEnumerator: + return "Failed to create files enumerator." + case .damagedData: + return "Can't encoding text to data." + case .missingCreationDate(let path): + return "Missing creationDate of item at path '\(path)'." + case .missingSize(let path): + return "Missing allocated size of item at path '\(path)'." + } + } +} diff --git a/Sources/Public/General/File.swift b/Sources/Public/General/File.swift new file mode 100644 index 0000000..0ebc2a4 --- /dev/null +++ b/Sources/Public/General/File.swift @@ -0,0 +1,50 @@ +// +// File.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +/// The convenient wrapper for interacting with the file features. +public enum File { + private static var storage: IFilesManager { Fish.sharedStorage } + + /// Returns true if the file exists. + /// - Parameter path: The file path. + public static func isExist(at path: String) -> Bool { + storage.isItemExist(at: path) + } + + /// Returns the file if it exists. + /// - Parameter name: The file path. + public static func at(_ path: String) throws -> IFile { + try storage.file(at: path) + } + + /// Creates a new file. + /// - Parameters: + /// - path: The file path. + /// - text: The string to write. + @discardableResult + public static func create( + at path: String, + contents text: String? = nil + ) throws -> IFile { + try storage.createFile(at: path, contents: text) + } + + /// Removes the file. + /// - Parameter path: The file path. + public static func delete(at path: String) throws { + try storage.deleteItem(at: path) + } + + /// Reads the string from the file. + /// - Parameter path: The file path. + public static func read(at path: String) throws -> String { + try storage.read(file: URL(fileURLWithPath: path)) + } +} diff --git a/Sources/Public/General/Folder.swift b/Sources/Public/General/Folder.swift new file mode 100644 index 0000000..48960fc --- /dev/null +++ b/Sources/Public/General/Folder.swift @@ -0,0 +1,60 @@ +// +// Folder.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +/// Returns true if the path is a directory. +public func isFolder(at path: String) -> Bool { + Fish.sharedStorage.isFolder(at: path) +} + +/// The convenient wrapper for interacting with the folder features. +public enum Folder { + private static var storage: IFilesManager { Fish.sharedStorage } + + /// Returns the path to the program’s current directory. + public static var current: IFolder { + try! storage.currentFolder() // swiftlint:disable:this force_try + } + + /// Returns the home directory for the current user. + public static var home: IFolder { + try! storage.homeFolder() // swiftlint:disable:this force_try + } + + /// Returns true if the directory exists. + /// - Parameter path: The folder path. + public static func isExist(at path: String) -> Bool { + storage.isFolder(at: path) && storage.isItemExist(at: path) + } + + /// Returns the folder if it exists. + /// - Parameter path: The folder path. + public static func at(_ path: String) throws -> IFolder { + try storage.folder(at: path) + } + + /// Creates a new folder. + /// - Parameter path: The folder path. + @discardableResult + public static func create(at path: String) throws -> IFolder { + try storage.createFolder(at: path) + } + + /// Removes the folder. + /// - Parameter path: The folder path. + public static func delete(at path: String) throws { + try storage.deleteItem(at: path) + } + + /// Obtains the folder size, in bytes. + /// - Parameter path: The folder path. + public static func size(at path: String) throws -> Int { + try storage.folderSize(at: path) + } +} diff --git a/Sources/Public/Interfaces/IFile.swift b/Sources/Public/Interfaces/IFile.swift new file mode 100644 index 0000000..2334d93 --- /dev/null +++ b/Sources/Public/Interfaces/IFile.swift @@ -0,0 +1,28 @@ +// +// IFile.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +/// The interface describing the interacting features of a file. +public protocol IFile: IItem { + /// Appends the string at the end of the file. + /// - Parameter text: The string to append. + func append(_ text: String) throws + + /// Writes the string to the file. + /// - Parameter text: The string to write. + func write(_ text: String) throws + + /// Reads the string from the file. + /// - Returns: The file content as a string. + func read() throws -> String + + /// Reads the data from the file. + /// - Returns: The file content as a data. + func readData() throws -> Data +} diff --git a/Sources/Public/Interfaces/IFilesManager.swift b/Sources/Public/Interfaces/IFilesManager.swift new file mode 100644 index 0000000..fcd5037 --- /dev/null +++ b/Sources/Public/Interfaces/IFilesManager.swift @@ -0,0 +1,147 @@ +// +// IFilesManager.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +/// Composition of all files manager protocols. +public typealias IFilesManager = IItemManager & IFileManager & IFolderManager + +// MARK: - IItemManager + +/// The interface describing the basic file or directory manager. +public protocol IItemManager { + /// Returns true if the file or directory exists at the path. + /// - Parameter path: The file or directory path. + func isItemExist(at path: String) -> Bool + + /// Removes the file or directory at the path. + /// - Parameter path: The file or directory path. + func deleteItem(at path: String) throws + + /// Moves the file or directory to a new location. + /// - Parameters: + /// - itemURL: The file or directory url. + /// - folderPath: The destination folder path. + /// - replace: Pass true if the file or directory needs to be replaced. + func moveItem( + at itemURL: URL, + to folderPath: String, + replace: Bool + ) throws + + /// Copies the file or directory to a new location. + /// - Parameters: + /// - itemURL: The file or directory url. + /// - folderPath: The destination folder path. + /// - replace: Pass true if the file or directory needs to be replaced. + func copyItem( + at itemURL: URL, + to folderPath: String, + replace: Bool + ) throws +} + +// MARK: - IFileManager + +/// The interface describing the file manager. +public protocol IFileManager { + /// Returns the file at the path if it exists. + /// - Parameter path: The file path. + func file(at path: String) throws -> IFile + + /// Finds files at the path. + /// - Parameters: + /// - path: The folder path. + /// - deep: Searches recursively if it's true. + func files( + at path: String, + deep: Bool + ) throws -> [IFile] + + /// Creates a new file at the path. + /// - Parameters: + /// - path: The file path. + /// - text: The string to write. + @discardableResult + func createFile( + at path: String, + contents text: String? + ) throws -> IFile + + // MARK: - Read/Write + + /// Appends the string at the end of the file at the url. + /// - Parameters: + /// - text: The string to append. + /// - file: The file url. + func append( + _ text: String, + to file: URL + ) throws + + /// Writes the string to the file at the url. + /// - Parameters: + /// - text: The string to write. + /// - file: The file url. + func write( + _ text: String, + to file: URL + ) throws + + /// Reads the string from the file at the url. + /// - Parameter file: The file url. + func read(file: URL) throws -> String + + /// Reads the data from the file at the url. + /// - Parameter file: The file url. + func readData(file: URL) throws -> Data +} + +// MARK: - IFolderManager + +/// The interface describing the directory manager. +public protocol IFolderManager { + /// Returns the path to the program’s current directory. + func currentFolder() throws -> IFolder + + /// Returns the home directory for the current user. + func homeFolder() throws -> IFolder + + /// Returns true if the path is a directory. + func isFolder(at path: String) -> Bool + + /// Returns the folder at the path if it exists. + /// - Parameter path: The folder path. + func folder(at path: String) throws -> IFolder + + /// Finds folders at the path. + /// - Parameters: + /// - path: The folder path. + /// - deep: Searches recursively if it's true. + func folders( + at path: String, + deep: Bool + ) throws -> [IFolder] + + /// Creates a new folder at the path. + /// - Parameter path: The folder path. + @discardableResult + func createFolder(at path: String) throws -> IFolder + + /// Checks if the folder is empty. + /// - Parameter path: The folder path. + func isFolderEmpty(at path: String) throws -> Bool + + /// Removes everything from the folder. + /// - Parameter path: The folder path. + func emptyFolder(at path: String) throws + + /// Obtains the folder size, in bytes. + /// - Parameter path: The folder path. + func folderSize(at path: String) throws -> Int +} diff --git a/Sources/Public/Interfaces/IFolder.swift b/Sources/Public/Interfaces/IFolder.swift new file mode 100644 index 0000000..4042c0c --- /dev/null +++ b/Sources/Public/Interfaces/IFolder.swift @@ -0,0 +1,77 @@ +// +// IFolder.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +/// The interface describing the interacting features of a folder. +public protocol IFolder: IItem { + /// Builds a new path adding passed components to the current one. + /// - Parameter pathComponents: The sequence of strings without slashes. + /// - Returns: A new built path with passed components. + func subpath(_ pathComponents: String...) -> String + + /// Returns the file with name if it exists in the current folder. + /// - Parameter name: The file name. + /// - Returns: A new file instance. + func file(named name: String) throws -> IFile + + /// Finds files in the current folder. + /// - Parameter deep: Searches recursively if it's true. + /// - Returns: An arrays with files. + func files(deep: Bool) throws -> [IFile] + + /// Finds folders in the current folder. + /// - Parameter deep: Searches recursively if it's true. + /// - Returns: An arrays with folders. + func folders(deep: Bool) throws -> [IFolder] + + /// Creates a new file in the current folder. + /// - Parameters: + /// - name: The file name. + /// - text: The string to write. + /// - Returns: A new file instance. + @discardableResult + func createFile( + named name: String, + contents text: String? + ) throws -> IFile + + /// Creates a new folder in the current one. + /// - Parameter name: The folder name + /// - Returns: A new folder instance. + @discardableResult + func createFolder(named name: String) throws -> IFolder + + /// Checks if the folder is empty. + func isEmpty() throws -> Bool + + /// Removes everything from the current folder. + func emptyFolder() throws +} + +public extension IFolder { + /// Finds files in the current folder. + /// - Returns: An arrays with files. + func files() throws -> [IFile] { + try files(deep: false) + } + + /// Finds folders in the current folder. + /// - Returns: An arrays with folders. + func folders() throws -> [IFolder] { + try folders(deep: false) + } + + /// Creates a new file in the current folder. + /// - Parameter name: The file name. + /// - Returns: A new file instance. + @discardableResult + func createFile(named name: String) throws -> IFile { + try createFile(named: name, contents: nil) + } +} diff --git a/Sources/Public/Interfaces/IItem.swift b/Sources/Public/Interfaces/IItem.swift new file mode 100644 index 0000000..807744d --- /dev/null +++ b/Sources/Public/Interfaces/IItem.swift @@ -0,0 +1,56 @@ +// +// IItem.swift +// Fish +// +// Created by Vyacheslav Khorkov on 19.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +/// The interface describing the basic interacting features of a file or directory. +public protocol IItem { + /// The file path. + var path: String { get } + + /// The path extension. + var pathExtension: String { get } + + /// The file name. + var name: String { get } + + /// The file name without the extension. + var nameExcludingExtension: String { get } + + /// The parent folder of the file. + var parent: IFolder? { get } + + // MARK: - Attributes + + /// Obtains the date the resource was created. + func creationDate() throws -> Date + + /// Obtains the resource size, in bytes. + func size() throws -> Int + + // MARK: - Location + + /// Returns a new path relative to the folder. + /// - Parameter folder: The folder with the common path prefix. + func relativePath(to folder: IFolder) -> String + + /// Removes the file or directory. + func delete() throws + + /// Moves the file or directory to a new location. + /// - Parameters: + /// - folderPath: The destination folder path. + /// - replace: Pass true if the file or directory needs to be replaced. + func move(to folderPath: String, replace: Bool) throws + + /// Copies the file or directory to a new location. + /// - Parameters: + /// - folderPath: The destination folder path. + /// - replace: Pass true if the file or directory needs to be replaced. + func copy(to folderPath: String, replace: Bool) throws +} diff --git a/Tests/FileTests.swift b/Tests/FileTests.swift new file mode 100644 index 0000000..2dfd542 --- /dev/null +++ b/Tests/FileTests.swift @@ -0,0 +1,112 @@ +// +// FileTests.swift +// Fish +// +// Created by Vyacheslav Khorkov on 13.08.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Fish +import XCTest + +final class FileTests: XCTestCase { + + private let fileManager = FileManager.default + private var testsFolder: IFolder! + + override func setUp() async throws { + try await super.setUp() + let tmp = fileManager.temporaryDirectory + let testsFolderURL = tmp.appendingPathComponent(.fake) + testsFolder = try Folder.create(at: testsFolderURL.path) + } + + override func tearDown() async throws { + try await super.tearDown() + try testsFolder.delete() + } +} + +// MARK: - Tests + +extension FileTests { + func test_isExist() throws { + let path = testsFolder.subpath(.fake) + fileManager.createFile(atPath: path, contents: nil) + + // Act + let isExist = File.isExist(at: path) + + // Assert + XCTAssertTrue(isExist) + } + + func test_at() throws { + let path = testsFolder.subpath(.fake) + fileManager.createFile(atPath: path, contents: nil) + + // Act + let file = try File.at(path) + + // Assert + XCTAssertEqual(file.path, path) + } + + func test_at_error() throws { + let path = testsFolder.subpath(.fake) + + // Act & Assert + var error: Error? + XCTAssertThrowsError(try File.at(path)) { error = $0 } + XCTAssertEqual(error as? FishError, FishError.fileNotFound(path)) + } + + func test_create_empty() throws { + let path = testsFolder.subpath(.fake) + + // Act + try File.create(at: path) + + // Assert + XCTAssertTrue(fileManager.fileExists(atPath: path)) + } + + func test_create_withContent() throws { + let path = testsFolder.subpath(.fake) + let content = String.fake + + // Act + try File.create(at: path, contents: content) + + // Assert + XCTAssertTrue(fileManager.fileExists(atPath: path)) + + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let resultContent = String(data: data, encoding: .utf8) + XCTAssertEqual(resultContent, content) + } + + func test_delete() throws { + let path = testsFolder.subpath(.fake) + fileManager.createFile(atPath: path, contents: nil) + + // Act + try File.delete(at: path) + + // Assert + XCTAssertFalse(fileManager.fileExists(atPath: path)) + } + + func test_read() throws { + let path = testsFolder.subpath(.fake) + let content = String.fake + let data = content.data(using: .utf8) + fileManager.createFile(atPath: path, contents: data) + + // Act + let resultContent = try File.read(at: path) + + // Assert + XCTAssertEqual(resultContent, content) + } +} diff --git a/Tests/FolderTests.swift b/Tests/FolderTests.swift new file mode 100644 index 0000000..0ce148f --- /dev/null +++ b/Tests/FolderTests.swift @@ -0,0 +1,140 @@ +// +// FolderTests.swift +// Fish +// +// Created by Vyacheslav Khorkov on 13.08.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Fish +import XCTest + +final class FolderTests: XCTestCase { + + private let fileManager = FileManager.default + private var testsFolder: (any IFolder)! + + override func setUp() async throws { + try await super.setUp() + let tmp = fileManager.temporaryDirectory + let testsFolderURL = tmp.appendingPathComponent(.fake) + testsFolder = try Folder.create(at: testsFolderURL.path) + } + + override func tearDown() async throws { + try await super.tearDown() + try testsFolder.delete() + } +} + +// MARK: - Tests + +extension FolderTests { + func test_isFolder_true() throws { + let path = testsFolder.subpath(.fake) + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: false) + + // Act + let isFolder = isFolder(at: path) + + // Assert + XCTAssertTrue(isFolder) + } + + func test_isFolder_false() throws { + let path = testsFolder.subpath(.fake) + fileManager.createFile(atPath: path, contents: nil) + + // Act + let isFolder = isFolder(at: path) + + // Assert + XCTAssertFalse(isFolder) + } + + func test_current() { + let currentFolderPath = Folder.current.path + + // Assert + XCTAssertEqual(currentFolderPath, fileManager.currentDirectoryPath) + } + + func test_home() { + let currentFolderPath = Folder.home.path + + // Assert + XCTAssertEqual(currentFolderPath, fileManager.homeDirectoryForCurrentUser.path) + } + + func test_isExist() throws { + let path = testsFolder.subpath(.fake) + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: false) + + // Act + let isExist = Folder.isExist(at: path) + + // Assert + XCTAssertTrue(isExist) + } + + func test_at() throws { + let path = testsFolder.subpath(.fake) + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: false) + + // Act + let folder = try Folder.at(path) + + // Assert + XCTAssertEqual(folder.path, path) + } + + func test_at_error() throws { + let path = testsFolder.subpath(.fake) + + // Act & Assert + var error: Error? + XCTAssertThrowsError(try Folder.at(path)) { error = $0 } + XCTAssertEqual(error as? FishError, FishError.folderNotFound(path)) + } + + func test_create() throws { + let path = testsFolder.subpath(.fake) + + // Act + try Folder.create(at: path) + + // Assert + var isFolder: ObjCBool = false + let isExist = fileManager.fileExists(atPath: path, isDirectory: &isFolder) + XCTAssertTrue(isExist) + XCTAssertTrue(isFolder.boolValue) + } + + func test_delete() throws { + let path = testsFolder.subpath(.fake) + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: false) + + // Act + try Folder.delete(at: path) + + // Assert + XCTAssertFalse(fileManager.fileExists(atPath: path)) + } + + func test_size() throws { + let folderPath = testsFolder.subpath(.fake) + try fileManager.createDirectory(atPath: folderPath, withIntermediateDirectories: false) + let folder = try Folder.at(folderPath) + try folder.createFile(named: .fake, contents: "new_file") // 8 + try folder.createFile(named: .fake, contents: "new_file1") // 9 + let subfolder = try folder.createFolder(named: .fake) + try subfolder.createFile(named: .fake, contents: "new_file12") // 10 + try subfolder.createFile(named: .fake, contents: "new_file123") // 11 + + // Act + let size = try Folder.size(at: folderPath) + + // Assert + XCTAssertEqual(size, 38) + } +} diff --git a/Tests/IFileTests.swift b/Tests/IFileTests.swift new file mode 100644 index 0000000..2fb3435 --- /dev/null +++ b/Tests/IFileTests.swift @@ -0,0 +1,222 @@ +// +// IFileTests.swift +// Fish +// +// Created by Vyacheslav Khorkov on 14.08.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Fish +import XCTest + +final class IFileTests: XCTestCase { + + private let fileManager = FileManager.default + private var testsFolder: (any IFolder)! + + override func setUp() async throws { + try await super.setUp() + let tmp = fileManager.temporaryDirectory + let testsFolderURL = tmp.appendingPathComponent(.fake) + testsFolder = try Folder.create(at: testsFolderURL.path) + } + + override func tearDown() async throws { + try await super.tearDown() + try testsFolder.delete() + } +} + +extension IFileTests { + func test_append() throws { + let content = String.fake + let file = try testsFolder.createFile(named: .fake, contents: content) + let appendContent = String.fake + + // Act + try file.append(appendContent) + + // Assert + XCTAssertEqual(try file.read(), content + appendContent) + } + + func test_write() throws { + let file = try testsFolder.createFile(named: .fake) + let content = String.fake + + // Act + try file.write(content) + + // Assert + XCTAssertEqual(try file.read(), content) + } + + func test_delete() throws { + let file = try testsFolder.createFile(named: .fake) + + // Act + try file.delete() + + // Assert + XCTAssertFalse(File.isExist(at: file.path)) + } + + func test_readData() throws { + let content = String.fake + let file = try testsFolder.createFile(named: .fake, contents: content) + + // Act + let resultData = try file.readData() + + // Assert + XCTAssertEqual(resultData, content.data(using: .utf8)) + } + + func test_files() throws { + let folder = try testsFolder.createFolder(named: .fake) + let files = try [ + folder.createFile(named: .fake), + folder.createFile(named: .fake), + folder.createFile(named: .fake) + ] + + // Act + let resultFiles = try folder.files() + + // Assert + XCTAssertEqual( + Set(resultFiles.map(\.path)), + Set(files.map(\.path)) + ) + } + + func test_pathExtension() throws { + let file = try testsFolder.createFile(named: "\(String.fake).txt") + + // Act && Assert + XCTAssertEqual(file.pathExtension, "txt") + } + + func test_nameExcludingExtension() throws { + let fileName = String.fake + let file = try testsFolder.createFile(named: "\(fileName).txt") + + // Act && Assert + XCTAssertEqual(file.nameExcludingExtension, fileName) + } + + func test_parent() throws { + let file = try testsFolder.createFile(named: .fake) + + // Act && Assert + XCTAssertNotNil(file.parent) + XCTAssertEqual(file.parent?.path, testsFolder.path) + } + + func test_relativePath() throws { + let folder = try testsFolder.createFolder(named: .fake) + let file = try folder.createFile(named: .fake) + + // Act + let relativePath = file.relativePath(to: testsFolder) + + // Assert + XCTAssertEqual(relativePath, "\(folder.name)/\(file.name)") + } + + func test_size() throws { + let content = "test_content" // 12 + let file = try testsFolder.createFile(named: .fake, contents: content) + + // Act && Assert + XCTAssertEqual(try file.size(), 12) + } + + func test_creationDate() throws { + let file = try testsFolder.createFile(named: .fake) + let url = URL(fileURLWithPath: file.path) + let creationDate = try url.resourceValues(forKeys: [.creationDateKey]).creationDate + + // Act && Assert + XCTAssertEqual(try file.creationDate(), creationDate) + } + + // MARK: - Move File + + func test_move_file() throws { + let file = try testsFolder.createFile(named: .fake) + let destinationPath = testsFolder.subpath(.fake) + + // Act + try file.move(to: destinationPath, replace: false) + + // Assert + XCTAssertTrue(File.isExist(at: destinationPath)) + XCTAssertFalse(File.isExist(at: file.path)) + } + + func test_move_file_failReplace() throws { + let file = try testsFolder.createFile(named: .fake) + let destinationFolder = try testsFolder.createFolder(named: .fake) + try destinationFolder.createFile(named: file.name) + + // Act & Assert + XCTAssertThrowsError(try file.move(to: destinationFolder.path, replace: false)) + } + + func test_move_file_replace() throws { + let content = String.fake + let file = try testsFolder.createFile(named: .fake, contents: content) + let destinationFolder = try testsFolder.createFolder(named: .fake) + try destinationFolder.createFile(named: file.name) + + // Act + try file.move(to: destinationFolder.path, replace: true) + + // Assert + XCTAssertFalse(File.isExist(at: file.path)) + + let movedFile = try destinationFolder.file(named: file.name) + XCTAssertEqual(try movedFile.read(), content) + } + + // MARK: - Copy File + + func test_copy_file() throws { + let file = try testsFolder.createFile(named: .fake) + let destinationPath = testsFolder.subpath(.fake) + + // Act + try file.copy(to: destinationPath, replace: false) + + // Assert + XCTAssertTrue(File.isExist(at: destinationPath)) + XCTAssertTrue(File.isExist(at: file.path)) + } + + func test_copy_file_failReplace() throws { + let file = try testsFolder.createFile(named: .fake) + let destinationFolder = try testsFolder.createFolder(named: .fake) + try destinationFolder.createFile(named: file.name) + + // Act & Assert + XCTAssertThrowsError(try file.copy(to: destinationFolder.path, replace: false)) + } + + func test_copy_file_replace() throws { + let content = String.fake + let file = try testsFolder.createFile(named: .fake, contents: content) + let destinationFolder = try testsFolder.createFolder(named: .fake) + try destinationFolder.createFile(named: file.name) + + // Act + try file.copy(to: destinationFolder.path, replace: true) + + // Assert + XCTAssertTrue(File.isExist(at: file.path)) + XCTAssertEqual(try File.read(at: file.path), content) + + let movedFile = try destinationFolder.file(named: file.name) + XCTAssertEqual(try movedFile.read(), content) + } +} diff --git a/Tests/IFolderTests.swift b/Tests/IFolderTests.swift new file mode 100644 index 0000000..9cb1c42 --- /dev/null +++ b/Tests/IFolderTests.swift @@ -0,0 +1,208 @@ +// +// IFolderTests.swift +// Fish +// +// Created by Vyacheslav Khorkov on 14.08.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Fish +import XCTest + +final class IFolderTests: XCTestCase { + + private let fileManager = FileManager.default + private var testsFolder: IFolder! + + override func setUp() async throws { + try await super.setUp() + let tmp = fileManager.temporaryDirectory + let testsFolderURL = tmp.appendingPathComponent(.fake) + testsFolder = try Folder.create(at: testsFolderURL.path) + } + + override func tearDown() async throws { + try await super.tearDown() + try testsFolder.delete() + } +} + +extension IFolderTests { + func test_isEmpty() throws { + let folder = try testsFolder.createFolder(named: .fake) + + // Act & Assert + XCTAssertTrue(try folder.isEmpty()) + } + + func test_empty() throws { + let folder = try testsFolder.createFolder(named: .fake) + try folder.createFile(named: .fake) + + // Act + try folder.emptyFolder() + + // Assert + XCTAssertTrue(try folder.isEmpty()) + } + + func test_folders() throws { + let folder = try testsFolder.createFolder(named: .fake) + let subfolders = try [ + folder.createFolder(named: .fake), + folder.createFolder(named: .fake), + folder.createFolder(named: .fake) + ] + + // Act + let resultFolders = try folder.folders() + + // Assert + XCTAssertEqual( + Set(resultFolders.map(\.path)), + Set(subfolders.map(\.path)) + ) + } + + func test_pathExtension() throws { + let folder = try testsFolder.createFolder(named: "\(String.fake).xcodeproj") + + // Act && Assert + XCTAssertEqual(folder.pathExtension, "xcodeproj") + } + + func test_nameExcludingExtension() throws { + let folderName = String.fake + let folder = try testsFolder.createFolder(named: "\(folderName).xcodeproj") + + // Act && Assert + XCTAssertEqual(folder.nameExcludingExtension, folderName) + } + + func test_parent() throws { + let folder = try testsFolder.createFolder(named: "\(String.fake).xcodeproj") + + // Act && Assert + XCTAssertNotNil(folder.parent) + XCTAssertEqual(folder.parent?.path, testsFolder.path) + } + + func test_relativePath() throws { + let folder = try testsFolder.createFolder(named: .fake) + let subfolder = try folder.createFolder(named: .fake) + + // Act + let relativePath = subfolder.relativePath(to: testsFolder) + + // Assert + XCTAssertEqual(relativePath, "\(folder.name)/\(subfolder.name)") + } + + func test_size() throws { + let folderPath = testsFolder.subpath(.fake) + try fileManager.createDirectory(atPath: folderPath, withIntermediateDirectories: false) + let folder = try Folder.at(folderPath) + try folder.createFile(named: .fake, contents: "new_file") // 8 + let subfolder = try folder.createFolder(named: .fake) + try subfolder.createFile(named: .fake, contents: "new_file1") // 9 + try subfolder.createFile(named: .fake, contents: "new_file12") // 10 + + // Act && Assert + XCTAssertEqual(try folder.size(), 27) + } + + func test_creationDate() throws { + let folder = try testsFolder.createFolder(named: .fake) + let url = URL(fileURLWithPath: folder.path) + let creationDate = try url.resourceValues(forKeys: [.creationDateKey]).creationDate + + // Act && Assert + XCTAssertEqual(try folder.creationDate(), creationDate) + } + + // MARK: - Move Folder + + func test_move_folder() throws { + let folder = try testsFolder.createFolder(named: .fake) + let destinationPath = testsFolder.subpath(.fake) + + // Act + try folder.move(to: destinationPath, replace: false) + + // Assert + XCTAssertTrue(Folder.isExist(at: destinationPath)) + XCTAssertFalse(Folder.isExist(at: folder.path)) + } + + func test_move_folder_failReplace() throws { + let folder = try testsFolder.createFolder(named: .fake) + let destinationFolder = try testsFolder.createFolder(named: .fake) + try destinationFolder.createFolder(named: folder.name) + + // Act & Assert + XCTAssertThrowsError(try folder.move(to: destinationFolder.path, replace: false)) + } + + func test_move_folder_replace() throws { + let folder = try testsFolder.createFolder(named: .fake) + let fileName = String.fake + try folder.createFile(named: fileName) + let destinationFolder = try testsFolder.createFolder(named: .fake) + try destinationFolder.createFolder(named: folder.name) + + // Act + try folder.move(to: destinationFolder.path, replace: true) + + // Assert + XCTAssertFalse(Folder.isExist(at: folder.path)) + + let movedFolderPath = destinationFolder.subpath(folder.name) + XCTAssertTrue(Folder.isExist(at: movedFolderPath)) + + let movedFileInFolderPath = destinationFolder.subpath(folder.name, fileName) + XCTAssertTrue(File.isExist(at: movedFileInFolderPath)) + } + + // MARK: - Copy Folder + + func test_copy_folder() throws { + let folder = try testsFolder.createFolder(named: .fake) + let destinationPath = testsFolder.subpath(.fake) + + // Act + try folder.copy(to: destinationPath, replace: false) + + // Assert + XCTAssertTrue(Folder.isExist(at: destinationPath)) + XCTAssertTrue(Folder.isExist(at: folder.path)) + } + + func test_copy_folder_failReplace() throws { + let folder = try testsFolder.createFolder(named: .fake) + let destinationFolder = try testsFolder.createFolder(named: .fake) + try destinationFolder.createFolder(named: folder.name) + + // Act & Assert + XCTAssertThrowsError(try folder.copy(to: destinationFolder.path, replace: false)) + } + + func test_copy_folder_replace() throws { + let folder = try testsFolder.createFolder(named: .fake) + let fileName = String.fake + try folder.createFile(named: fileName) + let destinationFolder = try testsFolder.createFolder(named: .fake) + try destinationFolder.createFolder(named: folder.name) + + // Act + try folder.copy(to: destinationFolder.path, replace: true) + + // Assert + XCTAssertTrue(Folder.isExist(at: folder.path)) + + let movedFolderPath = destinationFolder.subpath(folder.name) + XCTAssertTrue(Folder.isExist(at: movedFolderPath)) + + let movedFileInFolderPath = destinationFolder.subpath(folder.name, fileName) + XCTAssertTrue(File.isExist(at: movedFileInFolderPath)) + } +} diff --git a/Tests/StringTests.swift b/Tests/StringTests.swift new file mode 100644 index 0000000..c24056a --- /dev/null +++ b/Tests/StringTests.swift @@ -0,0 +1,19 @@ +// +// StringTests.swift +// FishTests +// +// Created by Vyacheslav Khorkov on 24.03.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Fish +import XCTest + +final class StringTests: XCTestCase { + + func test_relativePathTo() { + XCTAssertEqual("/Users/some/Rugby".relativePath(to: "/Users/some"), "Rugby") + XCTAssertEqual("/Users/some/Rugby".relativePath(to: "/Users/some/"), "Rugby") + XCTAssertEqual("/Users/some/Rugby".relativePath(to: "/some/"), "/Users/some/Rugby") + } +} diff --git a/Tests/Utils/String+Fake.swift b/Tests/Utils/String+Fake.swift new file mode 100644 index 0000000..3311480 --- /dev/null +++ b/Tests/Utils/String+Fake.swift @@ -0,0 +1,15 @@ +// +// String+Fake.swift +// Fish +// +// Created by Vyacheslav Khorkov on 14.08.2023. +// Copyright © 2023 Vyacheslav Khorkov. All rights reserved. +// + +import Foundation + +extension String { + static var fake: String { + UUID().uuidString + } +}