├── .swiftformatignore ├── .swiftheaderignore ├── .gitignore ├── Docker └── Dockerfile.testing ├── Tests ├── FileManagerKitBuilderTests │ ├── EncodeMe.swift │ ├── JSON.swift │ └── DirectoryBuilderTestSuite.swift └── FileManagerKitTests │ └── FileManagerKitTestSuite.swift ├── Sources ├── FileManagerKitBuilder │ ├── Build │ │ ├── BuildableItem.swift │ │ └── Buildable.swift │ ├── Items │ │ ├── Link.swift │ │ ├── Directory.swift │ │ └── File.swift │ └── FileManagerPlayground.swift └── FileManagerKit │ ├── FileManagerKit.swift │ └── FileManager+Kit.swift ├── LICENSE ├── Makefile ├── .github └── workflows │ └── actions.yml ├── Package.swift ├── .swift-format └── README.md /.swiftformatignore: -------------------------------------------------------------------------------- 1 | Package.swift -------------------------------------------------------------------------------- /.swiftheaderignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.txt 3 | *.html 4 | *.yaml 5 | README.md 6 | Package.resolved 7 | Makefile 8 | LICENSE 9 | Package.swift 10 | Docker/** 11 | scripts/** -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Docker/Dockerfile.testing: -------------------------------------------------------------------------------- 1 | FROM swift:6.0 2 | 3 | WORKDIR /app 4 | 5 | COPY . ./ 6 | 7 | RUN swift package resolve 8 | RUN swift package clean 9 | 10 | # CMD ["swift", "build", "-c", "release"] 11 | CMD ["swift", "test", "--parallel", "--enable-code-coverage"] 12 | -------------------------------------------------------------------------------- /Tests/FileManagerKitBuilderTests/EncodeMe.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EncodeMe.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 31.. 6 | // 7 | 8 | /// A codable model representing a basic entity with a single `name` property. 9 | /// 10 | /// This type is used to demonstrate or test JSON encoding capabilities. 11 | struct EncodeMe: Codable { 12 | /// The name value of the entity. 13 | let name: String 14 | } 15 | -------------------------------------------------------------------------------- /Sources/FileManagerKitBuilder/Build/BuildableItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BuildableItem.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | /// A protocol that defines an item that can be represented as a `FileManagerPlayground.Item`. 9 | /// 10 | /// Types conforming to this protocol can convert themselves into a file system representation, 11 | /// which can then be used to build a file hierarchy using the `FileManagerPlayground`. 12 | public protocol BuildableItem { 13 | 14 | /// Creates a `FileManagerPlayground.Item` representation of the conforming instance. 15 | /// 16 | /// - Returns: A value representing the file system item. 17 | func buildItem() -> FileManagerPlayground.Item 18 | } 19 | -------------------------------------------------------------------------------- /Sources/FileManagerKitBuilder/Build/Buildable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Buildable.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | import Foundation 9 | import FileManagerKit 10 | 11 | /// A protocol that defines the ability to create or assemble resources at a given file system path. 12 | /// 13 | /// Types conforming to `Buildable` implement the `build(at:using:)` method, which is responsible 14 | /// for constructing a file system hierarchy or resources at the specified location using a given file manager. 15 | protocol Buildable { 16 | 17 | /// Builds the conforming item at the specified file system location using the provided file manager. 18 | /// 19 | /// - Parameters: 20 | /// - path: The location where the item should be built. 21 | /// - fileManager: The file manager instance used to perform file system operations. 22 | /// - Throws: An error if the build process fails. 23 | func build( 24 | at path: URL, 25 | using fileManager: FileManagerKit 26 | ) throws 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2022 Tibor Bödecs 4 | Copyright (c) 2022-2025 Binary Birds Ltd. 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | 3 | .PHONY: docker 4 | 5 | baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/main/scripts 6 | 7 | check: symlinks language deps lint 8 | 9 | symlinks: 10 | curl -s $(baseUrl)/check-broken-symlinks.sh | bash 11 | 12 | language: 13 | curl -s $(baseUrl)/check-unacceptable-language.sh | bash 14 | 15 | deps: 16 | curl -s $(baseUrl)/check-local-swift-dependencies.sh | bash 17 | 18 | lint: 19 | curl -s $(baseUrl)/run-swift-format.sh | bash 20 | 21 | fmt: 22 | swiftformat . 23 | 24 | format: 25 | curl -s $(baseUrl)/run-swift-format.sh | bash -s -- --fix 26 | 27 | headers: 28 | curl -s $(baseUrl)/check-swift-headers.sh | bash 29 | 30 | fix-headers: 31 | curl -s $(baseUrl)/check-swift-headers.sh | bash -s -- --fix 32 | 33 | build: 34 | swift build 35 | 36 | release: 37 | swift build -c release 38 | 39 | test: 40 | swift test --parallel 41 | 42 | test-with-coverage: 43 | swift test --parallel --enable-code-coverage 44 | 45 | clean: 46 | rm -rf .build 47 | 48 | docker-tests: 49 | docker build -t file-manager-kit-tests . -f ./Docker/Dockerfile.testing && docker run --rm file-manager-kit-tests 50 | 51 | docker-run: 52 | docker run --rm -v $(pwd):/app -it swift:6.0 53 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: Actions 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | 10 | bb_checks: 11 | name: BB Checks 12 | uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main 13 | with: 14 | local_swift_dependencies_check_enabled : true 15 | 16 | swiftlang_checks: 17 | name: Swiftlang Checks 18 | uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main 19 | with: 20 | license_header_check_project_name: "Toucan" 21 | format_check_enabled : true 22 | broken_symlink_check_enabled : true 23 | unacceptable_language_check_enabled : true 24 | api_breakage_check_enabled : false 25 | docs_check_enabled : false 26 | license_header_check_enabled : false 27 | shell_check_enabled : false 28 | yamllint_check_enabled : false 29 | python_lint_check_enabled : false 30 | 31 | swiftlang_tests: 32 | name: Swiftlang Tests 33 | uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main 34 | with: 35 | enable_windows_checks : false 36 | linux_build_command: "swift test --parallel --enable-code-coverage" 37 | linux_exclude_swift_versions: "[{\"swift_version\": \"5.8\"}, {\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"nightly\"}, {\"swift_version\": \"nightly-main\"}, {\"swift_version\": \"nightly-6.0\"}, {\"swift_version\": \"nightly-6.1\"}]" -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "file-manager-kit", 6 | platforms: [ 7 | .macOS(.v14), 8 | .iOS(.v17), 9 | .tvOS(.v17), 10 | .watchOS(.v10), 11 | .visionOS(.v1), 12 | ], 13 | products: [ 14 | .library( 15 | name: "FileManagerKit", 16 | targets: ["FileManagerKit"] 17 | ), 18 | .library( 19 | name: "FileManagerKitBuilder", 20 | targets: ["FileManagerKitBuilder"] 21 | ), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "FileManagerKit", 26 | swiftSettings: [ 27 | .enableExperimentalFeature("StrictConcurrency=complete"), 28 | ] 29 | ), 30 | .target( 31 | name: "FileManagerKitBuilder", 32 | dependencies: [ 33 | .target(name: "FileManagerKit"), 34 | ], 35 | swiftSettings: [ 36 | .enableExperimentalFeature("StrictConcurrency=complete"), 37 | ] 38 | ), 39 | .testTarget( 40 | name: "FileManagerKitTests", 41 | dependencies: [ 42 | .target(name: "FileManagerKit"), 43 | .target(name: "FileManagerKitBuilder") 44 | ] 45 | ), 46 | .testTarget( 47 | name: "FileManagerKitBuilderTests", 48 | dependencies: [ 49 | .target(name: "FileManagerKit"), 50 | .target(name: "FileManagerKitBuilder") 51 | ] 52 | ), 53 | ] 54 | ) 55 | -------------------------------------------------------------------------------- /Tests/FileManagerKitBuilderTests/JSON.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSON.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | import FileManagerKit 9 | import FileManagerKitBuilder 10 | import Foundation 11 | 12 | /// A `BuildableItem` that generates a `.json` file from any `Encodable` type. 13 | /// 14 | /// This type encodes the provided Swift value to JSON using `JSONEncoder` with pretty-printed and sorted key formatting, 15 | /// and produces a file system representation suitable for use in a `FileManagerPlayground`. 16 | public struct JSON: BuildableItem { 17 | 18 | /// The base name of the JSON file (without extension). 19 | public let name: String 20 | /// The file extension, defaulting to `"json"`. 21 | public let ext: String 22 | /// The `Encodable` value that will be serialized to JSON. 23 | public let contents: T 24 | 25 | /// Creates a new `JSON` buildable item. 26 | /// 27 | /// - Parameters: 28 | /// - name: The base name of the JSON file. 29 | /// - ext: The file extension (defaults to `"json"`). 30 | /// - contents: The value to encode to JSON. 31 | public init( 32 | name: String, 33 | ext: String = "json", 34 | contents: T 35 | ) { 36 | self.name = name 37 | self.ext = ext 38 | self.contents = contents 39 | } 40 | 41 | /// Builds a `FileManagerPlayground.Item.file` from the encoded JSON contents. 42 | /// 43 | /// - Returns: A file representation containing the encoded JSON. 44 | public func buildItem() -> FileManagerPlayground.Item { 45 | let encoder = JSONEncoder() 46 | encoder.outputFormatting = [ 47 | .prettyPrinted, 48 | .sortedKeys, 49 | ] 50 | 51 | let data = try! encoder.encode(contents) 52 | let string = String(data: data, encoding: .utf8)! 53 | 54 | return .file( 55 | .init( 56 | name: "\(name).\(ext)", 57 | string: string 58 | ) 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/FileManagerKitBuilder/Items/Link.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Link.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | import Foundation 9 | import FileManagerKit 10 | 11 | /// A `Buildable` representation of a file system link, either symbolic or hard. 12 | /// 13 | /// The `Link` type allows you to define a link with a name, a target path, and the link type. 14 | public struct Link: Buildable { 15 | 16 | /// The name of the link to be created. 17 | let name: String 18 | /// The target path that the link points to, relative to the link's location. 19 | let target: String 20 | /// Indicates whether the link is symbolic (`true`) or a hard link (`false`). 21 | let isSymbolic: Bool 22 | 23 | /// Creates a new link with the specified name, target, and link type. 24 | /// 25 | /// - Parameters: 26 | /// - name: The name of the link to create. 27 | /// - target: The relative target path the link should point to. 28 | /// - isSymbolic: Whether to create a symbolic link (`true`, default) or hard link (`false`). 29 | public init( 30 | name: String, 31 | target: String, 32 | isSymbolic: Bool = true 33 | ) { 34 | self.name = name 35 | self.target = target 36 | self.isSymbolic = isSymbolic 37 | } 38 | 39 | /// Builds the link at the specified location using the provided file manager. 40 | /// 41 | /// - Parameters: 42 | /// - url: The URL where the link should be created. 43 | /// - fileManager: The file manager used to create the link. 44 | /// - Throws: An error if the link could not be created. 45 | func build( 46 | at url: URL, 47 | using fileManager: FileManagerKit 48 | ) throws { 49 | let linkUrl = url.appending(path: name) 50 | let targetUrl = url.appending(path: target) 51 | if isSymbolic { 52 | try fileManager.softLink(from: targetUrl, to: linkUrl) 53 | } 54 | else { 55 | try fileManager.hardLink(from: targetUrl, to: linkUrl) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy" : { 3 | "accessLevel" : "private" 4 | }, 5 | "indentation" : { 6 | "spaces" : 4 7 | }, 8 | "multiElementCollectionTrailingCommas": true, 9 | "indentConditionalCompilationBlocks" : false, 10 | "indentSwitchCaseLabels" : false, 11 | "lineBreakAroundMultilineExpressionChainComponents" : true, 12 | "lineBreakBeforeControlFlowKeywords" : true, 13 | "lineBreakBeforeEachArgument" : true, 14 | "lineBreakBeforeEachGenericRequirement" : true, 15 | "lineLength" : 80, 16 | "maximumBlankLines" : 1, 17 | "prioritizeKeepingFunctionOutputTogether" : true, 18 | "respectsExistingLineBreaks" : true, 19 | "rules" : { 20 | "AllPublicDeclarationsHaveDocumentation" : true, 21 | "AlwaysUseLowerCamelCase" : false, 22 | "AmbiguousTrailingClosureOverload" : true, 23 | "BeginDocumentationCommentWithOneLineSummary" : false, 24 | "DoNotUseSemicolons" : true, 25 | "DontRepeatTypeInStaticProperties" : false, 26 | "FileScopedDeclarationPrivacy" : true, 27 | "FullyIndirectEnum" : true, 28 | "GroupNumericLiterals" : true, 29 | "IdentifiersMustBeASCII" : true, 30 | "NeverForceUnwrap" : false, 31 | "NeverUseForceTry" : false, 32 | "NeverUseImplicitlyUnwrappedOptionals" : false, 33 | "NoAccessLevelOnExtensionDeclaration" : false, 34 | "NoAssignmentInExpressions" : true, 35 | "NoBlockComments" : true, 36 | "NoCasesWithOnlyFallthrough" : true, 37 | "NoEmptyTrailingClosureParentheses" : true, 38 | "NoLabelsInCasePatterns" : false, 39 | "NoLeadingUnderscores" : false, 40 | "NoParensAroundConditions" : true, 41 | "NoVoidReturnOnFunctionSignature" : true, 42 | "OneCasePerLine" : true, 43 | "OneVariableDeclarationPerLine" : true, 44 | "OnlyOneTrailingClosureArgument" : true, 45 | "OrderedImports" : false, 46 | "ReturnVoidInsteadOfEmptyTuple" : true, 47 | "UseEarlyExits" : false, 48 | "UseLetInEveryBoundCaseVariable" : false, 49 | "UseShorthandTypeNames" : true, 50 | "UseSingleLinePropertyGetter" : false, 51 | "UseSynthesizedInitializer" : true, 52 | "UseTripleSlashForDocumentationComments" : true, 53 | "UseWhereClausesInForLoops" : false, 54 | "ValidateDocumentationComments" : true 55 | }, 56 | "spacesAroundRangeFormationOperators" : false, 57 | "tabWidth" : 4, 58 | "version" : 1 59 | } 60 | -------------------------------------------------------------------------------- /Sources/FileManagerKitBuilder/Items/Directory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Directory.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | import Foundation 9 | import FileManagerKit 10 | 11 | /// A `Buildable` representation of a directory in the file system. 12 | /// 13 | /// The `Directory` type allows you to construct a directory with optional file attributes 14 | /// and nested file system items using a result builder. 15 | public struct Directory: Buildable { 16 | 17 | /// The name of the directory to create. 18 | let name: String 19 | /// The file attributes to apply to the directory, such as POSIX permissions. 20 | let attributes: [FileAttributeKey: Any]? 21 | /// The items contained within the directory, built using the `ItemBuilder` result builder. 22 | let contents: [FileManagerPlayground.Item] 23 | 24 | /// Creates a new directory with the specified name, optional attributes, and contents. 25 | /// 26 | /// - Parameters: 27 | /// - name: The name of the directory. 28 | /// - attributes: Optional file attributes such as permissions. 29 | /// - builder: A result builder that defines the contents of the directory. 30 | public init( 31 | name: String, 32 | attributes: [FileAttributeKey: Any]? = nil, 33 | @FileManagerPlayground.ItemBuilder _ builder: () -> 34 | [FileManagerPlayground.Item] = { [] } 35 | ) { 36 | self.name = name 37 | self.attributes = attributes 38 | self.contents = builder() 39 | } 40 | 41 | /// Builds the directory and its contents at the specified location using the provided file manager. 42 | /// 43 | /// - Parameters: 44 | /// - url: The URL where the directory should be created. 45 | /// - fileManager: The file manager to use for file system operations. 46 | /// - Throws: An error if the directory or its contents could not be created. 47 | func build( 48 | at url: URL, 49 | using fileManager: FileManagerKit 50 | ) throws { 51 | let dirUrl = url.appending(path: name) 52 | try fileManager.createDirectory( 53 | at: dirUrl, 54 | attributes: attributes 55 | ) 56 | 57 | for item in contents { 58 | try item.build(at: dirUrl, using: fileManager) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/FileManagerKitBuilder/Items/File.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | import Foundation 9 | import FileManagerKit 10 | 11 | /// A `Buildable` representation of a file in the file system. 12 | /// 13 | /// The `File` type allows you to define the name, optional attributes, and contents of a file. 14 | public struct File: ExpressibleByStringLiteral, Buildable { 15 | 16 | /// The name of the file to be created. 17 | let name: String 18 | /// Optional file attributes, such as POSIX permissions. 19 | let attributes: [FileAttributeKey: Any]? 20 | /// Optional data content to be written into the file. 21 | let contents: Data? 22 | 23 | /// Creates a new file with the given name, optional attributes, and binary contents. 24 | /// 25 | /// - Parameters: 26 | /// - name: The name of the file. 27 | /// - attributes: Optional file attributes. 28 | /// - contents: Optional data to write into the file. 29 | public init( 30 | name: String, 31 | attributes: [FileAttributeKey: Any]? = nil, 32 | contents: Data? = nil 33 | ) { 34 | self.name = name 35 | self.attributes = attributes 36 | self.contents = contents 37 | } 38 | 39 | /// Creates a new file with a UTF-8 encoded string as contents. 40 | /// 41 | /// - Parameters: 42 | /// - name: The name of the file. 43 | /// - attributes: Optional file attributes. 44 | /// - string: Optional string content to encode and write into the file. 45 | public init( 46 | name: String, 47 | attributes: [FileAttributeKey: Any]? = nil, 48 | string: String? = nil 49 | ) { 50 | self.init( 51 | name: name, 52 | attributes: attributes, 53 | contents: string?.data(using: .utf8) 54 | ) 55 | } 56 | 57 | /// Initializes a file from a string literal, using the literal as the file name and no contents. 58 | /// 59 | /// - Parameter value: The name of the file. 60 | public init( 61 | stringLiteral value: String 62 | ) { 63 | self.init(name: value, string: nil) 64 | } 65 | 66 | /// Builds the file at the specified location using the provided file manager. 67 | /// 68 | /// - Parameters: 69 | /// - url: The URL where the file should be created. 70 | /// - fileManager: The file manager used to perform file system operations. 71 | /// - Throws: An error if the file could not be created. 72 | func build( 73 | at url: URL, 74 | using fileManager: FileManagerKit 75 | ) throws { 76 | try fileManager.createFile( 77 | at: url.appending(path: name), 78 | contents: contents, 79 | attributes: attributes 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FileManagerKit 2 | 3 | Swift extensions and DSLs for filesystem testing, scripting, and inspection. 4 | 5 | This package contains two products: 6 | 7 | - FileManagerKit – high-level extensions for FileManager 8 | - FileManagerKitBuilder – a DSL for creating filesystem layouts (ideal for tests) 9 | 10 | Note: This repository is a work in progress. Expect breaking changes before v1.0.0. 11 | 12 | 13 | ## Installation 14 | 15 | Add the package to your `Package.swift` to the package dependencies section: 16 | 17 | ```swift 18 | .package(url: "https://github.com/binarybirds/file-manager-kit", .upToNextMinor(from: "0.4.0")), 19 | ``` 20 | 21 | Then add the library to the target dependencies: 22 | 23 | ```swift 24 | .product(name: "FileManagerKit", package: "file-manager-kit"), 25 | ``` 26 | 27 | Also add the other library too, if you need the builder: 28 | 29 | ```swift 30 | .product(name: "FileManagerKitBuilder", package: "file-manager-kit"), 31 | ``` 32 | 33 | 34 | ## Usage 35 | 36 | Here are a few common use-cases. 37 | 38 | ### FileManagerKit 39 | 40 | A set of ergonomic, safe extensions for working with FileManager. 41 | 42 | Check if file or directory exists: 43 | 44 | ```swift 45 | let fileURL = URL(filePath: "/path/to/file") 46 | if fileManager.exists(at: fileURL) { 47 | print("Exists!") 48 | } 49 | ``` 50 | 51 | Create a directory: 52 | 53 | ```swift 54 | let dirURL = URL(filePath: "/path/to/new-dir") 55 | try fileManager.createDirectory(at: dirURL) 56 | ``` 57 | 58 | Create a file: 59 | 60 | ```swift 61 | let fileURL = URL(filePath: "/path/to/file.txt") 62 | let data = "Hello".data(using: .utf8) 63 | try fileManager.createFile(at: fileURL, contents: data) 64 | ``` 65 | 66 | Delete a file or directory: 67 | 68 | ```swift 69 | let targetURL = URL(filePath: "/path/to/delete") 70 | try fileManager.delete(at: targetURL) 71 | ``` 72 | 73 | List directory contents: 74 | 75 | ```swift 76 | let contents = fileManager.listDirectory(at: URL(filePath: "/path/to/dir")) 77 | print(contents) 78 | ``` 79 | 80 | Copy and move: 81 | 82 | ```swift 83 | try fileManager.copy(from: URL(filePath: "/from"), to: URL(filePath: "/to")) 84 | 85 | try fileManager.move(from: URL(filePath: "/from"), to: URL(filePath: "/to")) 86 | ``` 87 | 88 | Get file size information: 89 | 90 | ```swift 91 | let size = try fileManager.size(at: URL(filePath: "/path/to/file")) 92 | print("\(size) bytes") 93 | ``` 94 | 95 | 96 | ### FileManagerKitBuilder 97 | 98 | A Swift DSL to declaratively build, inspect, and tear down file system structures — great for testing. 99 | 100 | 101 | Simple Example to create and clean up a file structure: 102 | 103 | ```swift 104 | import FileManagerKitBuilder 105 | 106 | let playground = FileManagerPlayground { 107 | Directory(name: "foo") { 108 | File(name: "bar.txt", string: "Hello, world!") 109 | } 110 | } 111 | 112 | let _ = try playground.build() 113 | try playground.remove() 114 | ``` 115 | 116 | Custom type example, you can use a `BuildableItem` to generate structured files (e.g., JSON): 117 | 118 | ```swift 119 | public struct JSON: BuildableItem { 120 | public let name: String 121 | public let contents: T 122 | 123 | public func buildItem() -> FileManagerPlayground.Item { 124 | let data = try! JSONEncoder().encode(contents) 125 | let string = String(data: data, encoding: .utf8)! 126 | return .file(File(name: "\(name).json", string: string)) 127 | } 128 | } 129 | 130 | struct User: Codable { let name: String } 131 | 132 | let playground = FileManagerPlayground { 133 | Directory(name: "data") { 134 | JSON(name: "user", contents: User(name: "Deku")) 135 | } 136 | } 137 | 138 | try playground.build() 139 | ``` 140 | 141 | Use `.test` to run assertions in a temporary sandbox: 142 | 143 | ```swift 144 | try FileManagerPlayground { 145 | Directory(name: "foo") { 146 | "bar.txt" 147 | } 148 | } 149 | .test { fileManager, rootUrl in 150 | let fileURL = rootUrl.appending(path: "foo/bar.txt") 151 | #expect(fileManager.fileExists(at: fileURL)) 152 | } 153 | ``` 154 | -------------------------------------------------------------------------------- /Tests/FileManagerKitBuilderTests/DirectoryBuilderTestSuite.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectoryBuilderTestSuite.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 04. 01.. 6 | // 7 | 8 | import Foundation 9 | import Testing 10 | 11 | @testable import FileManagerKitBuilder 12 | 13 | @Suite 14 | struct DirectoryBuilderTestSuite { 15 | 16 | @Test 17 | func builder_AllFeatures() throws { 18 | let includeOptional = true 19 | let useFirst = false 20 | let useThird = true 21 | let dynamicFiles = (1...2) 22 | .map { i in 23 | File(name: "dynamic\(i).txt", contents: nil) 24 | } 25 | let injected: [FileManagerPlayground.Item] = [ 26 | .file(File(name: "injected1.txt", contents: nil)), 27 | .file(File(name: "injected2.txt", contents: nil)), 28 | ] 29 | 30 | try FileManagerPlayground { 31 | Directory(name: "root") { 32 | File("static.md") 33 | 34 | if includeOptional { 35 | "optional.txt" 36 | } 37 | 38 | if useFirst { 39 | "first-choice.txt" 40 | } 41 | else { 42 | "second-choice.txt" 43 | } 44 | 45 | if useThird { 46 | "third-choice.txt" 47 | } 48 | else { 49 | File("forth-choice.txt") 50 | } 51 | 52 | Directory(name: "looped") { 53 | for file in dynamicFiles { 54 | file 55 | } 56 | } 57 | 58 | Directory(name: "empty") {} 59 | 60 | Directory(name: "nested") { 61 | Directory(name: "deeper") { 62 | "deep.txt" 63 | } 64 | } 65 | 66 | Link(name: "link", target: "static.md") 67 | 68 | injected 69 | 70 | // Multiple arrays in one block 71 | [ 72 | .file(File("array1.txt")), 73 | .file(File("array2.txt")), 74 | ] 75 | [ 76 | .file(File("array3.txt")) 77 | ] 78 | 79 | // Custom BuildableItem 80 | JSON(name: "encoded-name", contents: EncodeMe(name: "MyName")) 81 | } 82 | Directory(name: "not-root") { 83 | "string" 84 | JSON(name: "encoded-name", contents: EncodeMe(name: "MyName")) 85 | [ 86 | JSON( 87 | name: "encoded-name-me", 88 | contents: EncodeMe(name: "MyName") 89 | ), 90 | JSON( 91 | name: "encoded-name-you", 92 | contents: EncodeMe(name: "YourName") 93 | ), 94 | ] 95 | } 96 | } 97 | .test { fileManager, rootUrl in 98 | let checkPaths = [ 99 | "root/static.md", 100 | "root/optional.txt", 101 | "root/second-choice.txt", 102 | "root/third-choice.txt", 103 | "root/looped/dynamic1.txt", 104 | "root/looped/dynamic2.txt", 105 | "root/empty", 106 | "root/nested/deeper/deep.txt", 107 | "root/link", 108 | "root/injected1.txt", 109 | "root/injected2.txt", 110 | "root/array1.txt", 111 | "root/array2.txt", 112 | "root/array3.txt", 113 | "root/encoded-name.json", 114 | "not-root/string", 115 | "not-root/encoded-name.json", 116 | "not-root/encoded-name-me.json", 117 | "not-root/encoded-name-you.json", 118 | ] 119 | 120 | for path in checkPaths { 121 | let url = rootUrl.appending(path: path) 122 | #expect( 123 | fileManager.exists(at: url), 124 | "Expected file or directory at: \(path)" 125 | ) 126 | } 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/FileManagerKit/FileManagerKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManagerKit.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol that abstracts common file system operations, such as checking for file existence, 11 | /// creating directories or files, copying, moving, deleting, and querying file attributes. 12 | /// 13 | /// This protocol is useful for dependency injection and unit testing by allowing you to mock 14 | /// file system behavior in conforming types. 15 | public protocol FileManagerKit { 16 | 17 | /// The path to the current working directory. 18 | var currentDirectoryPath: String { get } 19 | 20 | /// The home directory URL for the current user. 21 | var homeDirectoryForCurrentUser: URL { get } 22 | 23 | /// The URL of the temporary directory. 24 | var temporaryDirectory: URL { get } 25 | 26 | // MARK: - 27 | 28 | /// Checks whether a file, directory, or link exists at the specified URL. 29 | /// 30 | /// - Parameter url: The URL to check for existence. 31 | /// - Returns: `true` if the item exists, otherwise `false`. 32 | func exists( 33 | at url: URL 34 | ) -> Bool 35 | 36 | /// Determines whether a directory exists at the specified URL. 37 | /// 38 | /// - Parameter url: The URL to check. 39 | /// - Returns: `true` if a directory exists at the URL, otherwise `false`. 40 | func directoryExists( 41 | at url: URL 42 | ) -> Bool 43 | 44 | /// Determines whether a file exists at the specified URL. 45 | /// 46 | /// - Parameter url: The URL to check. 47 | /// - Returns: `true` if a file exists at the URL, otherwise `false`. 48 | func fileExists( 49 | at url: URL 50 | ) -> Bool 51 | 52 | /// Determines whether a link exists at the specified URL. 53 | /// 54 | /// - Parameter url: The URL to check. 55 | /// - Returns: `true` if a link exists at the URL, otherwise `false`. 56 | func linkExists( 57 | at url: URL 58 | ) -> Bool 59 | 60 | // MARK: - 61 | 62 | /// Creates a directory at the specified URL with optional attributes. 63 | /// 64 | /// - Parameters: 65 | /// - url: The location where the directory should be created. 66 | /// - attributes: Optional file attributes to assign to the new directory. 67 | /// - Throws: An error if the directory could not be created. 68 | func createDirectory( 69 | at url: URL, 70 | attributes: [FileAttributeKey: Any]? 71 | ) throws 72 | 73 | /// Creates a file at the specified URL with optional contents and attributes. 74 | /// 75 | /// - Parameters: 76 | /// - url: The location where the file should be created. 77 | /// - contents: Optional data to write into the file. 78 | /// - attributes: Optional file attributes to apply to the file, such as permissions. 79 | /// - Throws: An error if the file could not be created. 80 | func createFile( 81 | at url: URL, 82 | contents: Data?, 83 | attributes: [FileAttributeKey: Any]? 84 | ) throws 85 | 86 | /// Copies a file or directory from a source URL to a destination URL. 87 | /// 88 | /// - Parameters: 89 | /// - source: The original location of the file or directory. 90 | /// - destination: The target location. 91 | /// - Throws: An error if the item could not be copied. 92 | func copy( 93 | from source: URL, 94 | to destination: URL 95 | ) throws 96 | 97 | /// Recursively copies a directory and its contents from a source URL to a destination URL. 98 | /// 99 | /// - Parameters: 100 | /// - inputURL: The root directory to copy. 101 | /// - outputURL: The destination root directory. 102 | /// - Throws: An error if the operation fails. 103 | func copyRecursively( 104 | from inputURL: URL, 105 | to outputURL: URL 106 | ) throws 107 | 108 | /// Moves a file or directory from a source URL to a destination URL. 109 | /// 110 | /// - Parameters: 111 | /// - source: The original location of the file or directory. 112 | /// - destination: The new location. 113 | /// - Throws: An error if the item could not be moved. 114 | func move( 115 | from source: URL, 116 | to destination: URL 117 | ) throws 118 | 119 | /// Creates a symbolic (soft) link from a source path to a destination path. 120 | /// 121 | /// - Parameters: 122 | /// - source: The target of the link. 123 | /// - destination: The location where the symbolic link should be created. 124 | /// - Throws: An error if the soft link could not be created. 125 | func softLink( 126 | from source: URL, 127 | to destination: URL 128 | ) throws 129 | 130 | /// Creates a hard link from a source path to a destination path. 131 | /// 132 | /// - Parameters: 133 | /// - source: The target of the link. 134 | /// - destination: The location where the hard link should be created. 135 | /// - Throws: An error if the hard link could not be created. 136 | func hardLink( 137 | from source: URL, 138 | to destination: URL 139 | ) throws 140 | 141 | /// Deletes the file, directory, or symbolic link at the specified URL. 142 | /// 143 | /// - Parameter url: The URL of the item to delete. 144 | /// - Throws: An error if the item could not be deleted. 145 | func delete( 146 | at url: URL 147 | ) throws 148 | 149 | // MARK: - 150 | 151 | /// Lists the contents of the directory at the specified URL. 152 | /// 153 | /// - Parameter url: The directory URL. 154 | /// - Returns: An array of item names in the directory. 155 | func listDirectory( 156 | at url: URL 157 | ) -> [String] 158 | 159 | /// Recursively lists all files and directories under the specified URL. 160 | /// 161 | /// - Parameter url: The root directory to list. 162 | /// - Returns: An array of URLs representing all items found recursively. 163 | func listDirectoryRecursively( 164 | at url: URL 165 | ) -> [URL] 166 | 167 | /// Finds file or directory names within a specified directory that match optional name or extension filters. 168 | /// 169 | /// This method can search recursively and optionally skip hidden files. 170 | /// 171 | /// - Parameters: 172 | /// - name: An optional base name to match (excluding the file extension). If `nil`, all names are matched. 173 | /// - extensions: An optional list of file extensions to match (e.g., `["txt", "md"]`). If `nil`, all extensions are matched. 174 | /// - recursively: Whether to include subdirectories in the search. 175 | /// - skipHiddenFiles: Whether to exclude hidden files and directories (those starting with a dot). 176 | /// - url: The root directory URL to search in. 177 | /// - Returns: A list of matching file or directory names as relative paths from the input URL. 178 | func find( 179 | name: String?, 180 | extensions: [String]?, 181 | recursively: Bool, 182 | skipHiddenFiles: Bool, 183 | at url: URL 184 | ) -> [String] 185 | 186 | // MARK: - 187 | 188 | /// Retrieves the file attributes at the specified URL. 189 | /// 190 | /// - Parameter url: The file or directory URL. 191 | /// - Returns: A dictionary of file attributes. 192 | /// - Throws: An error if attributes could not be retrieved. 193 | func attributes( 194 | at url: URL 195 | ) throws -> [FileAttributeKey: Any] 196 | 197 | /// Retrieves the POSIX permissions for the file or directory at the specified URL. 198 | /// 199 | /// - Parameter url: The file or directory URL. 200 | /// - Returns: The POSIX permission value. 201 | /// - Throws: An error if the permissions could not be retrieved. 202 | func permissions( 203 | at url: URL 204 | ) throws -> Int 205 | 206 | /// Returns the size of the file at the specified URL in bytes. 207 | /// 208 | /// - Parameter url: The file URL. 209 | /// - Returns: The size of the file in bytes. 210 | /// - Throws: An error if the size could not be retrieved. 211 | func size( 212 | at url: URL 213 | ) throws -> UInt64 214 | 215 | /// Retrieves the creation date of the item at the specified URL. 216 | /// 217 | /// - Parameter url: The file or directory URL. 218 | /// - Returns: The creation date. 219 | /// - Throws: An error if the creation date could not be retrieved. 220 | func creationDate( 221 | at url: URL 222 | ) throws -> Date 223 | 224 | /// Retrieves the last modification date of the item at the specified URL. 225 | /// 226 | /// - Parameter url: The file or directory URL. 227 | /// - Returns: The modification date. 228 | /// - Throws: An error if the modification date could not be retrieved. 229 | func modificationDate( 230 | at url: URL 231 | ) throws -> Date 232 | 233 | /// Sets the file attributes at the specified URL. 234 | /// 235 | /// - Parameters: 236 | /// - attributes: A dictionary of attributes to apply. 237 | /// - url: The file or directory URL. 238 | /// - Throws: An error if the attributes could not be set. 239 | func setAttributes( 240 | _ attributes: [FileAttributeKey: Any], 241 | at url: URL 242 | ) throws 243 | 244 | /// Sets the POSIX file permissions at the specified URL. 245 | /// 246 | /// - Parameters: 247 | /// - permission: The POSIX permission value. 248 | /// - url: The file or directory URL. 249 | /// - Throws: An error if the permissions could not be set. 250 | func setPermissions( 251 | _ permission: Int, 252 | at url: URL 253 | ) throws 254 | } 255 | -------------------------------------------------------------------------------- /Sources/FileManagerKitBuilder/FileManagerPlayground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManagerPlayground.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | import Foundation 9 | import FileManagerKit 10 | 11 | /// A utility type for creating, testing, and cleaning up temporary file system hierarchies using `FileManager`. 12 | /// 13 | /// This struct enables developers to declaratively define file structures using a builder DSL and run tests 14 | /// against them in isolation. 15 | public struct FileManagerPlayground { 16 | 17 | /// Represents a buildable file system item used within a `FileManagerPlayground` hierarchy. 18 | /// 19 | /// Each case corresponds to a file, directory, or symbolic/hard link. 20 | public enum Item: Buildable { 21 | case file(File) 22 | case directory(Directory) 23 | case link(Link) 24 | 25 | func build( 26 | at path: URL, 27 | using fileManager: FileManagerKit 28 | ) throws { 29 | switch self { 30 | case .file(let file): 31 | try file.build(at: path, using: fileManager) 32 | case .directory(let dir): 33 | try dir.build(at: path, using: fileManager) 34 | case .link(let link): 35 | try link.build(at: path, using: fileManager) 36 | } 37 | } 38 | } 39 | 40 | /// A result builder that produces an array of `Item` values from DSL-style syntax. 41 | @resultBuilder 42 | public enum ItemBuilder { 43 | 44 | /// Converts a single `BuildableItem` into a `FileManagerPlayground.Item`. 45 | /// 46 | /// - Parameter expression: A type conforming to `BuildableItem`. 47 | /// - Returns: An array containing one `Item` built from the input. 48 | public static func buildExpression( 49 | _ expression: T 50 | ) 51 | -> [Item] 52 | { 53 | [ 54 | expression.buildItem() 55 | ] 56 | } 57 | 58 | /// Converts an array of `BuildableItem`s into an array of `Item`s. 59 | /// 60 | /// - Parameter expressions: An array of `BuildableItem` types. 61 | /// - Returns: An array of built items. 62 | public static func buildExpression( 63 | _ expressions: [T] 64 | ) 65 | -> [Item] 66 | { 67 | expressions.map { 68 | $0.buildItem() 69 | } 70 | } 71 | 72 | /// Combines multiple `Item` arrays into a single array. 73 | /// 74 | /// - Parameter components: Variadic item arrays to flatten. 75 | /// - Returns: A single flat array of items. 76 | public static func buildBlock( 77 | _ components: [Item]... 78 | ) -> [Item] { 79 | components.flatMap { $0 } 80 | } 81 | 82 | /// Wraps a specific `Buildable` item type into a corresponding `Item` case. 83 | /// 84 | /// - Parameter expression: A file/directory/link object. 85 | /// - Returns: An array containing one wrapped item. 86 | public static func buildExpression( 87 | _ expression: File 88 | ) -> [Item] { 89 | [ 90 | .file(expression) 91 | ] 92 | } 93 | 94 | /// Wraps a specific `Buildable` item type into a corresponding `Item` case. 95 | /// 96 | /// - Parameter expression: A file/directory/link object. 97 | /// - Returns: An array containing one wrapped item. 98 | public static func buildExpression( 99 | _ expression: Directory 100 | ) -> [Item] { 101 | [ 102 | .directory(expression) 103 | ] 104 | } 105 | 106 | /// Wraps a specific `Buildable` item type into a corresponding `Item` case. 107 | /// 108 | /// - Parameter expression: A file/directory/link object. 109 | /// - Returns: An array containing one wrapped item. 110 | public static func buildExpression( 111 | _ expression: Link 112 | ) -> [Item] { 113 | [ 114 | .link(expression) 115 | ] 116 | } 117 | 118 | /// Treats a string literal as a file with no contents. 119 | /// 120 | /// - Parameter expression: The string name of the file. 121 | /// - Returns: An array with a `.file` item. 122 | public static func buildExpression( 123 | _ expression: String 124 | ) -> [Item] { 125 | [ 126 | .file( 127 | .init(name: expression, contents: nil) 128 | ) 129 | ] 130 | } 131 | 132 | /// Passes through a pre-constructed array of items. 133 | /// 134 | /// - Parameter expression: An array of items. 135 | /// - Returns: The same array. 136 | public static func buildExpression( 137 | _ expression: [Item] 138 | ) -> [Item] { 139 | expression 140 | } 141 | 142 | /// Supports conditional branches and optional item blocks in the builder. 143 | /// 144 | /// - Parameter component: Conditional or optional content branches. 145 | /// - Returns: The resolved item array. 146 | public static func buildOptional( 147 | _ component: [Item]? 148 | ) -> [Item] { 149 | component ?? [] 150 | } 151 | 152 | /// Supports conditional branches and optional item blocks in the builder. 153 | /// 154 | /// - Parameter component: Conditional or optional content branches. 155 | /// - Returns: The resolved item array. 156 | public static func buildEither( 157 | first component: [Item] 158 | ) -> [Item] { 159 | component 160 | } 161 | 162 | /// Supports conditional branches and optional item blocks in the builder. 163 | /// 164 | /// - Parameter component: Conditional or optional content branches. 165 | /// - Returns: The resolved item array. 166 | public static func buildEither( 167 | second component: [Item] 168 | ) -> [Item] { 169 | component 170 | } 171 | 172 | /// Supports conditional branches and optional item blocks in the builder. 173 | /// 174 | /// - Parameter components: Conditional or optional content branches. 175 | /// - Returns: The resolved item array. 176 | public static func buildArray( 177 | _ components: [[Item]] 178 | ) -> [Item] { 179 | components.flatMap { $0 } 180 | } 181 | } 182 | 183 | /// The file manager instance used for file system operations. 184 | private let fileManager: FileManager 185 | 186 | /// The root `Directory` object representing the file structure to be built. 187 | private let directory: Directory 188 | 189 | /// The base URL at which the root directory will be created. 190 | private let rootUrl: URL 191 | 192 | /// The full URL of the playground directory where the file structure will reside. 193 | public let playgroundDirUrl: URL 194 | 195 | /// Initializes a new `FileManagerPlayground` instance with a root directory and file structure. 196 | /// 197 | /// - Parameters: 198 | /// - rootUrl: Optional base path to build the playground in. Defaults to a temporary directory. 199 | /// - rootName: Optional root folder name. Defaults to a unique name. 200 | /// - fileManager: The file manager instance to use for operations. 201 | /// - builder: A DSL block that defines the file system structure. 202 | public init( 203 | rootUrl: URL? = nil, 204 | rootName: String? = nil, 205 | fileManager: FileManager = .default, 206 | @ItemBuilder _ builder: () -> [Item] = { [] } 207 | ) { 208 | self.fileManager = fileManager 209 | self.rootUrl = rootUrl ?? self.fileManager.temporaryDirectory 210 | self.directory = .init( 211 | name: rootName ?? "FileManagerPlayground_\(UUID().uuidString)", 212 | builder 213 | ) 214 | self.playgroundDirUrl = self.rootUrl.appending(path: directory.name) 215 | } 216 | 217 | /// Builds the file hierarchy in the file system using the specified root and file manager. 218 | /// 219 | /// - Returns: A tuple of the file manager and the root URL where the structure was created. 220 | /// - Throws: An error if the structure could not be built. 221 | @discardableResult 222 | public func build() throws -> (FileManager, URL) { 223 | try directory.build(at: rootUrl, using: fileManager) 224 | return (fileManager, playgroundDirUrl) 225 | } 226 | 227 | /// Removes the root directory created by the playground, if it exists. 228 | /// 229 | /// - Returns: A tuple of the file manager and the root URL that was deleted (if any). 230 | /// - Throws: An error if the directory could not be removed. 231 | @discardableResult 232 | public func remove() throws -> (FileManager, URL) { 233 | if fileManager.exists(at: playgroundDirUrl) { 234 | try fileManager.delete(at: playgroundDirUrl) 235 | } 236 | return (fileManager, playgroundDirUrl) 237 | } 238 | 239 | /// Builds the hierarchy, runs a test block, and ensures the playground directory is deleted afterward. 240 | /// 241 | /// - Parameter testBlock: A block receiving the file manager and root URL to run assertions against. 242 | /// - Throws: Any error thrown by the test block or file system operations. 243 | public func test( 244 | _ testBlock: (FileManager, URL) throws -> Void 245 | ) throws { 246 | try directory.build(at: rootUrl, using: fileManager) 247 | try testBlock(fileManager, playgroundDirUrl) 248 | try fileManager.delete(at: playgroundDirUrl) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /Sources/FileManagerKit/FileManager+Kit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager+Kit.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 05. 30.. 6 | // 7 | 8 | import Foundation 9 | 10 | #if os(Linux) 11 | import Glibc 12 | #else 13 | import Darwin 14 | #endif 15 | 16 | private extension URL { 17 | 18 | /// Computes a relative path from the current URL (`self`) to another base URL. 19 | /// 20 | /// This method compares the standardized path components of both URLs, 21 | /// identifies their shared prefix, and removes it from the current URL path 22 | /// to return a relative path string. 23 | /// 24 | /// - Parameter url: The base URL to which the path should be made relative. 25 | /// - Returns: A relative path string from `url` to `self`. 26 | func relativePath(to url: URL) -> String { 27 | // Break both paths into components (standardized removes '.', '..', etc.) 28 | let components = standardized.pathComponents 29 | let baseComponents = url.standardized.pathComponents 30 | 31 | // Determine how many leading components are shared between both paths 32 | let commonPrefixCount = zip(components, baseComponents) 33 | .prefix { $0 == $1 } 34 | .count 35 | 36 | // Remove the common prefix to compute the relative path 37 | let relativeComponents = components.dropFirst(commonPrefixCount) 38 | 39 | // Join the remaining components with "/" to form the relative path 40 | return relativeComponents.joined(separator: "/") 41 | } 42 | } 43 | 44 | extension FileManager: FileManagerKit { 45 | 46 | // MARK: - 47 | 48 | /// Checks whether a file, directory, or link exists at the specified URL. 49 | /// 50 | /// - Parameter url: The URL to check for existence. 51 | /// - Returns: `true` if the item exists, otherwise `false`. 52 | public func exists( 53 | at url: URL 54 | ) -> Bool { 55 | fileExists( 56 | atPath: url.path( 57 | percentEncoded: false 58 | ) 59 | ) 60 | } 61 | 62 | /// Determines whether a directory exists at the specified URL. 63 | /// 64 | /// - Parameter url: The URL to check. 65 | /// - Returns: `true` if a directory exists at the URL, otherwise `false`. 66 | public func directoryExists( 67 | at url: URL 68 | ) -> Bool { 69 | var isDirectory = ObjCBool(false) 70 | if fileExists( 71 | atPath: url.path(percentEncoded: false), 72 | isDirectory: &isDirectory 73 | ) { 74 | return isDirectory.boolValue 75 | } 76 | return false 77 | } 78 | 79 | /// Determines whether a file exists at the specified URL. 80 | /// 81 | /// - Parameter url: The URL to check. 82 | /// - Returns: `true` if a file exists at the URL, otherwise `false`. 83 | public func fileExists( 84 | at url: URL 85 | ) -> Bool { 86 | var isDirectory = ObjCBool(false) 87 | if fileExists( 88 | atPath: url.path( 89 | percentEncoded: false 90 | ), 91 | isDirectory: &isDirectory 92 | ) { 93 | return !isDirectory.boolValue 94 | } 95 | return false 96 | } 97 | 98 | /// Determines whether a link exists at the specified URL. 99 | /// 100 | /// - Parameter url: The URL to check. 101 | /// - Returns: `true` if a link exists at the URL, otherwise `false`. 102 | public func linkExists( 103 | at url: URL 104 | ) -> Bool { 105 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 106 | let resourceValues = try? url.resourceValues(forKeys: [ 107 | .isSymbolicLinkKey 108 | ]) 109 | if let isSymbolicLink = resourceValues?.isSymbolicLink { 110 | return isSymbolicLink 111 | } 112 | #else 113 | var statInfo = stat() 114 | if lstat(url.path(percentEncoded: false), &statInfo) == 0 { 115 | return (statInfo.st_mode & S_IFMT) == S_IFLNK 116 | } 117 | #endif 118 | return false 119 | } 120 | 121 | // MARK: - 122 | 123 | /// Creates a directory at the specified URL with optional attributes. 124 | /// 125 | /// - Parameters: 126 | /// - url: The location where the directory should be created. 127 | /// - attributes: Optional file attributes to assign to the new directory. 128 | /// - Throws: An error if the directory could not be created. 129 | public func createDirectory( 130 | at url: URL, 131 | attributes: [FileAttributeKey: Any]? 132 | ) throws { 133 | guard !directoryExists(at: url) else { 134 | return 135 | } 136 | try createDirectory( 137 | atPath: url.path(percentEncoded: false), 138 | withIntermediateDirectories: true, 139 | attributes: attributes 140 | ) 141 | } 142 | 143 | /// Creates a file at the specified URL with optional contents and attributes. 144 | /// 145 | /// - Parameters: 146 | /// - url: The location where the file should be created. 147 | /// - contents: Optional data to write into the file. 148 | /// - attributes: Optional file attributes to apply to the file, such as permissions. 149 | /// - Throws: An error if the file could not be created. 150 | public func createFile( 151 | at url: URL, 152 | contents: Data?, 153 | attributes: [FileAttributeKey: Any]? 154 | ) throws { 155 | guard 156 | createFile( 157 | atPath: url.path(percentEncoded: false), 158 | contents: contents, 159 | attributes: attributes 160 | ) 161 | else { 162 | throw CocoaError(.fileWriteUnknown) 163 | } 164 | } 165 | 166 | /// Copies a file or directory from a source URL to a destination URL. 167 | /// 168 | /// - Parameters: 169 | /// - source: The original location of the file or directory. 170 | /// - destination: The target location. 171 | /// - Throws: An error if the item could not be copied. 172 | public func copy( 173 | from source: URL, 174 | to destination: URL 175 | ) throws { 176 | try copyItem(at: source, to: destination) 177 | } 178 | 179 | /// Recursively copies a directory and its contents from a source URL to a destination URL. 180 | /// 181 | /// - Parameters: 182 | /// - inputURL: The root directory to copy. 183 | /// - outputURL: The destination root directory. 184 | /// - Throws: An error if the operation fails. 185 | public func copyRecursively( 186 | from inputURL: URL, 187 | to outputURL: URL 188 | ) throws { 189 | guard directoryExists(at: inputURL) else { 190 | return 191 | } 192 | if !directoryExists(at: outputURL) { 193 | try createDirectory(at: outputURL, attributes: nil) 194 | } 195 | 196 | for item in listDirectory(at: inputURL) { 197 | let path = item.removingPercentEncoding ?? item 198 | let itemSourceUrl = inputURL.appending(path: path) 199 | let itemDestinationUrl = outputURL.appending(path: path) 200 | if fileExists(at: itemSourceUrl) { 201 | if fileExists(at: itemDestinationUrl) { 202 | try delete(at: itemDestinationUrl) 203 | } 204 | try copy(from: itemSourceUrl, to: itemDestinationUrl) 205 | } 206 | else { 207 | try copyRecursively(from: itemSourceUrl, to: itemDestinationUrl) 208 | } 209 | } 210 | } 211 | 212 | /// Moves a file or directory from a source URL to a destination URL. 213 | /// 214 | /// - Parameters: 215 | /// - source: The original location of the file or directory. 216 | /// - destination: The new location. 217 | /// - Throws: An error if the item could not be moved. 218 | public func move( 219 | from source: URL, 220 | to destination: URL 221 | ) throws { 222 | try moveItem(at: source, to: destination) 223 | } 224 | 225 | /// Creates a symbolic (soft) link from a source path to a destination path. 226 | /// 227 | /// - Parameters: 228 | /// - source: The target of the link. 229 | /// - destination: The location where the symbolic link should be created. 230 | /// - Throws: An error if the soft link could not be created. 231 | public func softLink( 232 | from source: URL, 233 | to destination: URL 234 | ) throws { 235 | try createSymbolicLink( 236 | at: destination, 237 | withDestinationURL: source 238 | ) 239 | } 240 | 241 | /// Creates a hard link from a source path to a destination path. 242 | /// 243 | /// - Parameters: 244 | /// - source: The target of the link. 245 | /// - destination: The location where the hard link should be created. 246 | /// - Throws: An error if the hard link could not be created. 247 | public func hardLink( 248 | from source: URL, 249 | to destination: URL 250 | ) throws { 251 | try linkItem(at: source, to: destination) 252 | } 253 | 254 | /// Deletes the file, directory, or symbolic link at the specified URL. 255 | /// 256 | /// - Parameter url: The URL of the item to delete. 257 | /// - Throws: An error if the item could not be deleted. 258 | public func delete(at url: URL) throws { 259 | try removeItem(at: url) 260 | } 261 | 262 | // MARK: - 263 | 264 | /// Lists the contents of the directory at the specified URL. 265 | /// 266 | /// - Parameter url: The directory URL. 267 | /// - Returns: An array of item names in the directory. 268 | public func listDirectory( 269 | at url: URL 270 | ) -> [String] { 271 | guard directoryExists(at: url) else { 272 | return [] 273 | } 274 | let list = try? contentsOfDirectory(atPath: url.path) 275 | return list?.map { $0 } ?? [] 276 | } 277 | 278 | /// Recursively lists all files and directories under the specified URL. 279 | /// 280 | /// - Parameter url: The root directory to list. 281 | /// - Returns: An array of URLs representing all items found recursively. 282 | public func listDirectoryRecursively( 283 | at url: URL 284 | ) -> [URL] { 285 | let list = listDirectory(at: url) 286 | 287 | return list.reduce(into: [URL]()) { result, path in 288 | let itemUrl = url.appending(path: path) 289 | 290 | if directoryExists(at: itemUrl) { 291 | result += listDirectoryRecursively(at: itemUrl) 292 | } 293 | else { 294 | result.append(itemUrl) 295 | } 296 | } 297 | } 298 | 299 | /// Finds file or directory names within a specified directory that match optional name or extension filters. 300 | /// 301 | /// This method can search recursively and optionally skip hidden files. 302 | /// 303 | /// - Parameters: 304 | /// - name: An optional base name to match (excluding the file extension). If `nil`, all names are matched. 305 | /// - extensions: An optional list of file extensions to match (e.g., `["txt", "md"]`). If `nil`, all extensions are matched. 306 | /// - recursively: Whether to include subdirectories in the search. 307 | /// - skipHiddenFiles: Whether to exclude hidden files and directories (those starting with a dot). 308 | /// - url: The root directory URL to search in. 309 | /// - Returns: A list of matching file or directory names as relative paths from the input URL. 310 | public func find( 311 | name: String? = nil, 312 | extensions: [String]? = nil, 313 | recursively: Bool = false, 314 | skipHiddenFiles: Bool = true, 315 | at url: URL 316 | ) -> [String] { 317 | var items: [String] = [] 318 | if recursively { 319 | items = listDirectoryRecursively(at: url) 320 | .map { 321 | // Convert to a relative path based on the root URL 322 | $0.relativePath(to: url) 323 | } 324 | } 325 | else { 326 | items = listDirectory(at: url) 327 | } 328 | 329 | if skipHiddenFiles { 330 | items = items.filter { !$0.hasPrefix(".") } 331 | } 332 | 333 | return items.filter { fileName in 334 | let fileURL = URL(fileURLWithPath: fileName) 335 | let baseName = fileURL.deletingPathExtension().lastPathComponent 336 | let ext = fileURL.pathExtension 337 | 338 | switch (name, extensions) { 339 | case (nil, nil): 340 | return true 341 | case (let name?, nil): 342 | return baseName == name 343 | case (nil, let extensions?): 344 | return extensions.contains(ext) 345 | case let (name?, extensions?): 346 | return baseName == name && extensions.contains(ext) 347 | } 348 | } 349 | } 350 | 351 | // MARK: - 352 | 353 | /// Retrieves the file attributes at the specified URL. 354 | /// 355 | /// - Parameter url: The file or directory URL. 356 | /// - Returns: A dictionary of file attributes. 357 | /// - Throws: An error if attributes could not be retrieved. 358 | public func attributes( 359 | at url: URL 360 | ) throws -> [FileAttributeKey: Any] { 361 | try attributesOfItem( 362 | atPath: url.path( 363 | percentEncoded: false 364 | ) 365 | ) 366 | } 367 | 368 | /// Retrieves the POSIX permissions for the file or directory at the specified URL. 369 | /// 370 | /// - Parameter url: The file or directory URL. 371 | /// - Returns: The POSIX permission value. 372 | /// - Throws: An error if the permissions could not be retrieved. 373 | public func permissions( 374 | at url: URL 375 | ) throws -> Int { 376 | let attributes = try attributes(at: url) 377 | return attributes[.posixPermissions] as! Int 378 | } 379 | 380 | /// Returns the size of the file at the specified URL in bytes. 381 | /// 382 | /// - Parameter url: The file URL. 383 | /// - Returns: The size of the file in bytes. 384 | /// - Throws: An error if the size could not be retrieved. 385 | public func size( 386 | at url: URL 387 | ) throws -> UInt64 { 388 | if fileExists(at: url) { 389 | let attributes = try attributes(at: url) 390 | let size = attributes[.size] as! NSNumber 391 | return size.uint64Value 392 | } 393 | let keys: Set = [ 394 | .isRegularFileKey, 395 | .fileAllocatedSizeKey, 396 | .totalFileAllocatedSizeKey, 397 | ] 398 | guard 399 | let enumerator = enumerator( 400 | at: url, 401 | includingPropertiesForKeys: Array(keys) 402 | ) 403 | else { 404 | return 0 405 | } 406 | 407 | var size: UInt64 = 0 408 | for item in enumerator.compactMap({ $0 as? URL }) { 409 | let values = try item.resourceValues(forKeys: keys) 410 | guard values.isRegularFile ?? false else { 411 | continue 412 | } 413 | size += UInt64( 414 | values.totalFileAllocatedSize ?? values.fileAllocatedSize ?? 0 415 | ) 416 | } 417 | return size 418 | } 419 | 420 | /// Retrieves the creation date of the item at the specified URL. 421 | /// 422 | /// - Parameter url: The file or directory URL. 423 | /// - Returns: The creation date. 424 | /// - Throws: An error if the creation date could not be retrieved. 425 | public func creationDate( 426 | at url: URL 427 | ) throws -> Date { 428 | let attr = try attributes(at: url) 429 | // On Linux, we return the modification date, since no .creationDate 430 | return attr[.creationDate] as? Date ?? attr[.modificationDate] as! Date 431 | } 432 | 433 | /// Retrieves the last modification date of the item at the specified URL. 434 | /// 435 | /// - Parameter url: The file or directory URL. 436 | /// - Returns: The modification date. 437 | /// - Throws: An error if the modification date could not be retrieved. 438 | public func modificationDate( 439 | at url: URL 440 | ) throws -> Date { 441 | let attr = try attributes(at: url) 442 | return attr[.modificationDate] as! Date 443 | } 444 | 445 | // MARK: - 446 | 447 | /// Sets the file attributes at the specified URL. 448 | /// 449 | /// - Parameters: 450 | /// - attributes: A dictionary of attributes to apply. 451 | /// - url: The file or directory URL. 452 | /// - Throws: An error if the attributes could not be set. 453 | public func setAttributes( 454 | _ attributes: [FileAttributeKey: Any], 455 | at url: URL 456 | ) throws { 457 | try setAttributes( 458 | attributes, 459 | ofItemAtPath: url.path( 460 | percentEncoded: false 461 | ) 462 | ) 463 | } 464 | 465 | /// Sets the POSIX file permissions at the specified URL. 466 | /// 467 | /// - Parameters: 468 | /// - permission: The POSIX permission value. 469 | /// - url: The file or directory URL. 470 | /// - Throws: An error if the permissions could not be set. 471 | public func setPermissions( 472 | _ permission: Int, 473 | at url: URL 474 | ) throws { 475 | try setAttributes([.posixPermissions: permission], at: url) 476 | } 477 | 478 | } 479 | -------------------------------------------------------------------------------- /Tests/FileManagerKitTests/FileManagerKitTestSuite.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManagerKitTestSuite.swift 3 | // file-manager-kit 4 | // 5 | // Created by Viasz-Kádi Ferenc on 2025. 04. 01.. 6 | // 7 | 8 | import FileManagerKitBuilder 9 | import Foundation 10 | import Testing 11 | 12 | @testable import FileManagerKit 13 | 14 | @Suite 15 | struct FileManagerKitTestSuite { 16 | 17 | // MARK: - exists(at:) Tests 18 | 19 | @Test 20 | func exists_whenFileExists() throws { 21 | try FileManagerPlayground { 22 | Directory(name: "foo") { 23 | "bar" 24 | } 25 | } 26 | .test { fileManager, rootUrl in 27 | let url = rootUrl.appending(path: "foo/bar") 28 | 29 | #expect(fileManager.exists(at: url)) 30 | } 31 | } 32 | 33 | @Test 34 | func exists_whenFileDoesNotExist() throws { 35 | try FileManagerPlayground() 36 | .test { 37 | let url = $1.appending(path: "does/not/exist") 38 | 39 | #expect(!$0.exists(at: url)) 40 | } 41 | } 42 | 43 | // MARK: - fileExists(at:) Tests 44 | 45 | @Test 46 | func fileExists_whenFileExists() throws { 47 | try FileManagerPlayground { 48 | Directory(name: "foo") { 49 | "bar" 50 | } 51 | } 52 | .test { 53 | let url = $1.appending(path: "foo/bar") 54 | 55 | #expect($0.fileExists(at: url)) 56 | } 57 | } 58 | 59 | @Test 60 | func fileExists_whenFolderExists() throws { 61 | try FileManagerPlayground { 62 | Directory(name: "foo") { 63 | Directory(name: "bar") 64 | } 65 | } 66 | .test { 67 | let url = $1.appending(path: "foo/bar") 68 | 69 | #expect(!$0.fileExists(at: url)) 70 | } 71 | } 72 | 73 | @Test 74 | func fileExists_whenFileDoesNotExist() throws { 75 | try FileManagerPlayground() 76 | .test { 77 | let url = $1.appending(path: "does/not/exist") 78 | 79 | #expect(!$0.fileExists(at: url)) 80 | } 81 | } 82 | 83 | // MARK: - directoryExists(at:) Tests 84 | 85 | @Test 86 | func directoryExists_whenDirectoryExists() throws { 87 | try FileManagerPlayground { 88 | Directory(name: "foo") { 89 | "bar" 90 | } 91 | } 92 | .test { 93 | let url = $1.appending(path: "foo") 94 | 95 | #expect($0.directoryExists(at: url)) 96 | } 97 | } 98 | 99 | @Test 100 | func directoryExists_whenFileExists() throws { 101 | try FileManagerPlayground { 102 | Directory(name: "foo") { 103 | "bar" 104 | } 105 | } 106 | .test { 107 | let url = $1.appending(path: "foo/bar") 108 | 109 | #expect(!$0.directoryExists(at: url)) 110 | } 111 | } 112 | 113 | @Test 114 | func directoryExists_whenDirectoryDoesNotExist() throws { 115 | try FileManagerPlayground() 116 | .test { 117 | let url = $1.appending(path: "does/not/exist") 118 | 119 | #expect(!$0.directoryExists(at: url)) 120 | } 121 | } 122 | 123 | // MARK: - createFile(at:) Tests 124 | 125 | @Test 126 | func createFile_whenCreatesFileSuccessfully() throws { 127 | try FileManagerPlayground() 128 | .test { 129 | let url = $1.appending(path: "foo") 130 | try $0.createFile( 131 | at: url, 132 | contents: nil, 133 | attributes: nil 134 | ) 135 | 136 | #expect($0.fileExists(at: url)) 137 | } 138 | } 139 | 140 | @Test 141 | func createFile_whenIntermediateDirectoriesMissing() throws { 142 | try FileManagerPlayground() 143 | .test { fileManager, rootUrl in 144 | let url = rootUrl.appending(path: "foo/bar/baz") 145 | 146 | #expect( 147 | throws: CocoaError(.fileWriteUnknown), 148 | performing: { 149 | try fileManager.createFile( 150 | at: url, 151 | contents: nil, 152 | attributes: nil 153 | ) 154 | } 155 | ) 156 | } 157 | } 158 | 159 | @Test 160 | func createFile_whenFileAlreadyExists() throws { 161 | try FileManagerPlayground { 162 | Directory(name: "foo") { 163 | "bar" 164 | } 165 | } 166 | .test { 167 | let url = $1.appending(path: "foo/bar") 168 | let dataToWrite = "data".data(using: .utf8) 169 | try $0.createFile( 170 | at: url, 171 | contents: dataToWrite, 172 | attributes: nil 173 | ) 174 | let data = $0.contents(atPath: url.path(percentEncoded: false)) 175 | 176 | #expect(dataToWrite == data) 177 | } 178 | } 179 | 180 | // MARK: - createDirectory(at:) Tests 181 | 182 | @Test 183 | func createDirectory_whenCreatesDirectorySuccessfully() throws { 184 | try FileManagerPlayground() 185 | .test { 186 | let url = $1.appending(path: "foo") 187 | try $0.createDirectory( 188 | at: url, 189 | attributes: nil 190 | ) 191 | 192 | #expect($0.directoryExists(at: url)) 193 | } 194 | } 195 | 196 | @Test 197 | func createDirectory_whenDirectoryAlreadyExists() throws { 198 | try FileManagerPlayground { 199 | Directory(name: "foo") { 200 | Directory(name: "bar") 201 | } 202 | } 203 | .test { 204 | let url = $1.appending(path: "foo/bar") 205 | try $0.createDirectory( 206 | at: url, 207 | attributes: nil 208 | ) 209 | 210 | #expect($0.directoryExists(at: url)) 211 | } 212 | } 213 | 214 | // MARK: - delete(at:) Tests 215 | 216 | @Test 217 | func delete_whenFileExists() throws { 218 | try FileManagerPlayground { 219 | Directory(name: "foo") { 220 | "bar" 221 | } 222 | } 223 | .test { 224 | let url = $1.appending(path: "foo/bar") 225 | try $0.delete(at: url) 226 | 227 | #expect(!$0.fileExists(at: url)) 228 | } 229 | } 230 | 231 | @Test 232 | func delete_whenDirectoryExists() throws { 233 | try FileManagerPlayground { 234 | Directory(name: "foo") 235 | } 236 | .test { 237 | let url = $1.appending(path: "foo") 238 | try $0.delete(at: url) 239 | 240 | #expect(!$0.directoryExists(at: url)) 241 | } 242 | } 243 | 244 | @Test 245 | func delete_whenDirectoryDoesNotExist() throws { 246 | try FileManagerPlayground() 247 | .test { 248 | let url = $1.appending(path: "foo") 249 | 250 | do { 251 | try $0.delete(at: url) 252 | #expect(Bool(false)) 253 | } 254 | catch let error as NSError { 255 | #expect(error.domain == NSCocoaErrorDomain) 256 | #expect(error.code == 4) 257 | } 258 | } 259 | } 260 | 261 | // MARK: - listDirectory(at:) Tests 262 | 263 | @Test 264 | func listDirectory_whenDirectoryHasContent() throws { 265 | try FileManagerPlayground { 266 | Directory(name: "a") 267 | Directory(name: "b") 268 | Directory(name: "c") 269 | File(name: ".foo", string: "foo") 270 | "bar" 271 | Link(name: "bar_link", target: "bar") 272 | } 273 | .test { 274 | let items = $0.listDirectory(at: $1).sorted() 275 | #expect(items == [".foo", "a", "b", "bar", "bar_link", "c"]) 276 | } 277 | } 278 | 279 | @Test 280 | func listDirectory_whenDirectoryIsEmpty() throws { 281 | try FileManagerPlayground { 282 | Directory(name: "foo") 283 | } 284 | .test { 285 | let url = $1.appending(path: "foo") 286 | let items = $0.listDirectory(at: url) 287 | #expect(items == []) 288 | } 289 | } 290 | 291 | @Test 292 | func listDirectory_whenPathIsNotDirectory() throws { 293 | try FileManagerPlayground { 294 | "foo" 295 | } 296 | .test { 297 | let url = $1.appending(path: "foo") 298 | let items = $0.listDirectory(at: url) 299 | #expect(items == []) 300 | } 301 | } 302 | 303 | @Test 304 | func listDirectory_whenDirectoryNameIsSpecial() throws { 305 | try FileManagerPlayground { 306 | Directory(name: "a a") 307 | Directory(name: "b b") 308 | Directory(name: "c c") 309 | } 310 | .test { 311 | let expectation = [ 312 | "a a", 313 | "b b", 314 | "c c", 315 | ] 316 | let items = $0.listDirectory(at: $1).sorted() 317 | #expect(items == expectation) 318 | } 319 | } 320 | 321 | // MARK: - copy(from:to:) Tests 322 | 323 | @Test 324 | func copy_whenSourceExists() throws { 325 | try FileManagerPlayground { 326 | "source" 327 | } 328 | .test { 329 | let source = $1.appending(path: "source") 330 | let destination = $1.appending(path: "destination") 331 | 332 | try $0.copy(from: source, to: destination) 333 | 334 | #expect($0.fileExists(at: destination)) 335 | let originalData = $0.contents(atPath: source.path()) 336 | let copiedData = $0.contents(atPath: destination.path()) 337 | #expect(originalData == copiedData) 338 | } 339 | } 340 | 341 | @Test 342 | func copy_whenSourceDoesNotExist() throws { 343 | try FileManagerPlayground() 344 | .test { 345 | let source = $1.appending(path: "nonexistent") 346 | let destination = $1.appending(path: "destination") 347 | 348 | do { 349 | try $0.copy(from: source, to: destination) 350 | #expect(Bool(false)) 351 | } 352 | catch let error as NSError { 353 | #expect(error.domain == NSCocoaErrorDomain) 354 | #expect(error.code == 260) 355 | } 356 | } 357 | } 358 | 359 | @Test 360 | func copy_whenDestinationAlreadyExists() throws { 361 | try FileManagerPlayground { 362 | "source" 363 | "destination" 364 | } 365 | .test { 366 | let source = $1.appending(path: "source") 367 | let destination = $1.appending(path: "destination") 368 | 369 | do { 370 | try $0.copy(from: source, to: destination) 371 | #expect(Bool(false)) 372 | } 373 | catch let error as NSError { 374 | #expect(error.domain == NSCocoaErrorDomain) 375 | #expect(error.code == 516) 376 | } 377 | } 378 | } 379 | 380 | // MARK: - move(from:to:) Tests 381 | 382 | @Test 383 | func move_whenSourceExists() throws { 384 | try FileManagerPlayground { 385 | "source" 386 | } 387 | .test { 388 | let source = $1.appending(path: "source") 389 | let destination = $1.appending(path: "destination") 390 | 391 | try $0.move(from: source, to: destination) 392 | 393 | #expect(!$0.fileExists(at: source)) 394 | #expect($0.fileExists(at: destination)) 395 | } 396 | } 397 | 398 | @Test 399 | func move_whenSourceDoesNotExist() throws { 400 | try FileManagerPlayground() 401 | .test { 402 | let source = $1.appending(path: "nonexistent") 403 | let destination = $1.appending(path: "destination") 404 | 405 | do { 406 | try $0.move(from: source, to: destination) 407 | #expect(Bool(false)) 408 | } 409 | catch let error as NSError { 410 | #expect(error.domain == NSCocoaErrorDomain) 411 | #expect(error.code == 4) 412 | } 413 | } 414 | } 415 | 416 | @Test 417 | func move_whenDestinationAlreadyExists() throws { 418 | try FileManagerPlayground { 419 | "source" 420 | "destination" 421 | } 422 | .test { 423 | let source = $1.appending(path: "source") 424 | let destination = $1.appending(path: "destination") 425 | 426 | do { 427 | try $0.move(from: source, to: destination) 428 | #expect(Bool(false)) 429 | } 430 | catch let error as NSError { 431 | #expect(error.domain == NSCocoaErrorDomain) 432 | #expect(error.code == 516) 433 | } 434 | } 435 | } 436 | 437 | // MARK: - link(from:to:) Tests 438 | 439 | @Test 440 | func link_whenSourceExists() throws { 441 | try FileManagerPlayground { 442 | File(name: "sourceFile", string: "Hello, world!") 443 | } 444 | .test { 445 | let source = $1.appending(path: "sourceFile") 446 | let destination = $1.appending(path: "destination") 447 | try $0.softLink(from: source, to: destination) 448 | let exists = $0.exists(at: destination) 449 | let attributes = try $0.attributes(at: destination) 450 | let fileType = attributes[.type] as? FileAttributeType 451 | 452 | #expect($0.linkExists(at: destination)) 453 | #expect(exists) 454 | #expect(fileType == .typeSymbolicLink) 455 | } 456 | } 457 | 458 | @Test 459 | func link_whenSourceDoesNotExist() throws { 460 | try FileManagerPlayground() 461 | .test { 462 | let source = $1.appending(path: "missingSource") 463 | let destination = $1.appending(path: "destination") 464 | try $0.softLink(from: source, to: destination) 465 | let attributes = try $0.attributes(at: destination) 466 | let fileType = attributes[.type] as? FileAttributeType 467 | 468 | #expect(fileType == .typeSymbolicLink) 469 | 470 | #expect($0.linkExists(at: destination)) 471 | 472 | // Check that resolving the symlink gives the expected (absolute) path 473 | let resolvedPath = try $0.destinationOfSymbolicLink( 474 | atPath: destination.path() 475 | ) 476 | #expect( 477 | URL(fileURLWithPath: resolvedPath).lastPathComponent 478 | == source.lastPathComponent 479 | ) 480 | 481 | // Check that the target file does not exist (dangling link) 482 | #expect(!$0.fileExists(atPath: resolvedPath)) 483 | } 484 | } 485 | 486 | @Test 487 | func link_whenDestinationAlreadyExists() throws { 488 | try FileManagerPlayground { 489 | "source" 490 | "destination" 491 | } 492 | .test { 493 | let source = $1.appending(path: "source") 494 | let destination = $1.appending(path: "destination") 495 | 496 | do { 497 | try $0.softLink(from: source, to: destination) 498 | #expect(Bool(false)) 499 | } 500 | catch let error as NSError { 501 | #expect(error.domain == NSCocoaErrorDomain) 502 | #expect(error.code == 516) 503 | } 504 | } 505 | } 506 | 507 | // MARK: - creationDate(at:) Tests 508 | 509 | @Test 510 | func creationDate_whenFileExists() throws { 511 | try FileManagerPlayground { 512 | "file" 513 | } 514 | .test { 515 | let file = $1.appending(path: "file") 516 | 517 | let creationDate = try $0.creationDate(at: file) 518 | let attributes = try $0.attributesOfItem(atPath: file.path()) 519 | 520 | let creationDateAttribute = attributes[.creationDate] as? Date 521 | let modDateAttribute = attributes[.modificationDate] as! Date 522 | let dateAttrbiute = creationDateAttribute ?? modDateAttribute 523 | 524 | #expect(creationDate == dateAttrbiute) 525 | } 526 | } 527 | 528 | @Test 529 | func creationDate_whenFileDoesNotExist() throws { 530 | try FileManagerPlayground() 531 | .test { 532 | let file = $1.appending(path: "nonexistent") 533 | 534 | do { 535 | _ = try $0.creationDate(at: file) 536 | #expect(Bool(false)) 537 | } 538 | catch let error as NSError { 539 | #expect(error.domain == NSCocoaErrorDomain) 540 | #expect(error.code == 260) 541 | } 542 | } 543 | } 544 | 545 | // MARK: - modificationDate(at:) Tests 546 | 547 | @Test 548 | func modificationDate_whenFileExists() throws { 549 | try FileManagerPlayground { 550 | "file" 551 | } 552 | .test { 553 | let file = $1.appending(path: "file") 554 | 555 | let modificationDate = try $0.modificationDate(at: file) 556 | let attributes = try $0.attributesOfItem(atPath: file.path()) 557 | 558 | #expect(modificationDate == attributes[.modificationDate] as? Date) 559 | } 560 | } 561 | 562 | @Test 563 | func modificationDate_whenFileDoesNotExist() throws { 564 | try FileManagerPlayground() 565 | .test { 566 | let file = $1.appending(path: "nonexistent") 567 | 568 | do { 569 | _ = try $0.modificationDate(at: file) 570 | #expect(Bool(false)) 571 | } 572 | catch let error as NSError { 573 | #expect(error.domain == NSCocoaErrorDomain) 574 | #expect(error.code == 260) 575 | } 576 | } 577 | } 578 | 579 | // MARK: - size(at:) Tests 580 | 581 | @Test 582 | func size_whenFileExists() throws { 583 | try FileManagerPlayground { 584 | let text = "Hello, world!" 585 | File(name: "file", string: text) 586 | } 587 | .test { 588 | let file = $1.appending(path: "file") 589 | 590 | let fileSize = try $0.size(at: file) 591 | let expectedSize = "Hello, world!".utf8.count 592 | 593 | #expect(fileSize == expectedSize) 594 | } 595 | } 596 | 597 | @Test 598 | func size_whenFileDoesNotExist() throws { 599 | try FileManagerPlayground() 600 | .test { 601 | let file = $1.appending(path: "nonexistent") 602 | let size = try $0.size(at: file) 603 | 604 | #expect(size == 0) 605 | } 606 | } 607 | 608 | // MARK: - setAttributes(at:attributes:) Tests 609 | 610 | @Test 611 | func setAttributes_whenFileExists() throws { 612 | try FileManagerPlayground { 613 | "file" 614 | } 615 | .test { 616 | let url = $1.appending(path: "file") 617 | 618 | let newDate = Date(timeIntervalSince1970: 10000) 619 | let attributes: [FileAttributeKey: Any] = [ 620 | .modificationDate: newDate 621 | ] 622 | try $0.setAttributes(attributes, at: url) 623 | let updatedAttributes = try $0.attributes(at: url) 624 | 625 | #expect(updatedAttributes[.modificationDate] as? Date == newDate) 626 | } 627 | } 628 | 629 | @Test 630 | func setAttributes_whenFileDoesNotExist() throws { 631 | try FileManagerPlayground() 632 | .test { 633 | let url = $1.appending(path: "nonexistent") 634 | 635 | do { 636 | let attributes: [FileAttributeKey: Any] = [ 637 | .modificationDate: Date() 638 | ] 639 | try $0.setAttributes(attributes, at: url) 640 | #expect(Bool(false)) 641 | } 642 | catch let error as NSError { 643 | #expect(error.domain == NSCocoaErrorDomain) 644 | #expect(error.code == 4) 645 | } 646 | } 647 | } 648 | 649 | // MARK: - setPermissions(at:permissions:) Tests 650 | 651 | @Test 652 | func setPermissions_whenFileExists() throws { 653 | try FileManagerPlayground { 654 | "file" 655 | } 656 | .test { 657 | let url = $1.appending(path: "file") 658 | 659 | let permissions = 600 660 | try $0.setPermissions(permissions, at: url) 661 | let updatedPermissions = try $0.permissions(at: url) 662 | 663 | #expect(updatedPermissions == permissions) 664 | } 665 | } 666 | 667 | @Test 668 | func setPermissions_whenFileDoesNotExist() throws { 669 | try FileManagerPlayground() 670 | .test { 671 | let url = $1.appending(path: "nonexistent") 672 | 673 | do { 674 | try $0.setPermissions(600, at: url) 675 | #expect(Bool(false)) 676 | } 677 | catch let error as NSError { 678 | #expect(error.domain == NSCocoaErrorDomain) 679 | #expect(error.code == 4) 680 | } 681 | } 682 | } 683 | 684 | @Test 685 | func listDirectoryRecursively() throws { 686 | try FileManagerPlayground { 687 | Directory(name: "foo") { 688 | Directory(name: "bar") { 689 | Directory(name: "baz") { 690 | "boop" 691 | } 692 | "beep" 693 | } 694 | "bap" 695 | } 696 | } 697 | .test { 698 | let baseUrlLength = $1.path().count + 1 699 | let results = $0.listDirectoryRecursively(at: $1) 700 | .map { String($0.path().dropFirst(baseUrlLength)) } 701 | .sorted() 702 | let expected = [ 703 | "foo/bap", 704 | "foo/bar/baz/boop", 705 | "foo/bar/beep", 706 | ] 707 | .sorted() 708 | #expect(expected == results) 709 | } 710 | } 711 | 712 | @Test 713 | func listDirectoryRecursivelyWithSpecialCharacters() throws { 714 | try FileManagerPlayground { 715 | Directory(name: "f oo") { 716 | Directory(name: "ba r") { 717 | Directory(name: "b az") { 718 | "bo op" 719 | } 720 | "bee p" 721 | } 722 | "b ap" 723 | } 724 | } 725 | .test { 726 | let baseUrlLength = $1.path().count + 1 727 | let results = $0.listDirectoryRecursively(at: $1) 728 | .map { 729 | String( 730 | $0.path(percentEncoded: false).dropFirst(baseUrlLength) 731 | ) 732 | } 733 | .sorted() 734 | let expected = [ 735 | "f oo/b ap", 736 | "f oo/ba r/bee p", 737 | "f oo/ba r/b az/bo op", 738 | ] 739 | .sorted() 740 | 741 | #expect(expected == results) 742 | } 743 | } 744 | 745 | @Test 746 | func copyDirectoryRecursively() throws { 747 | try FileManagerPlayground { 748 | Directory(name: "from") { 749 | Directory(name: "foo") { 750 | "bap" 751 | Directory(name: "bar") { 752 | "beep" 753 | Directory(name: "baz") { 754 | "boop" 755 | } 756 | } 757 | } 758 | } 759 | Directory(name: "to") 760 | } 761 | .test { 762 | let from = $1.appending(path: "from") 763 | let to = $1.appending(path: "to") 764 | 765 | try $0.copyRecursively(from: from, to: to) 766 | 767 | let baseUrlLength = to.path().count + 1 768 | let results = $0.listDirectoryRecursively(at: to) 769 | .map { String($0.path().dropFirst(baseUrlLength)) } 770 | .sorted() 771 | 772 | let expected = [ 773 | "foo/bap", 774 | "foo/bar/baz/boop", 775 | "foo/bar/beep", 776 | ] 777 | .sorted() 778 | 779 | #expect(expected == results) 780 | } 781 | } 782 | 783 | @Test 784 | func copyDirectoryRecursivelyWithSpecialCharacters() throws { 785 | try FileManagerPlayground { 786 | Directory(name: "from") { 787 | Directory(name: "f oo") { 788 | "bap" 789 | Directory(name: "bar") { 790 | "beep" 791 | Directory(name: "baz") { 792 | "boop" 793 | } 794 | } 795 | } 796 | } 797 | Directory(name: "to") 798 | } 799 | .test { 800 | let from = $1.appending(path: "from") 801 | let to = $1.appending(path: "to") 802 | 803 | try $0.copyRecursively(from: from, to: to) 804 | 805 | let baseUrlLength = to.path().count + 1 806 | let results = $0.listDirectoryRecursively(at: to) 807 | .map { 808 | String( 809 | $0.path(percentEncoded: false).dropFirst(baseUrlLength) 810 | ) 811 | } 812 | .sorted() 813 | let expected = [ 814 | "f oo/bap", 815 | "f oo/bar/beep", 816 | "f oo/bar/baz/boop", 817 | ] 818 | .sorted() 819 | 820 | #expect(expected == results) 821 | } 822 | } 823 | 824 | @Test 825 | func extraParams() throws { 826 | let fileManager = FileManager.default 827 | let rootUrl = fileManager.temporaryDirectory 828 | let rootName = "test" 829 | 830 | try FileManagerPlayground( 831 | rootUrl: rootUrl, 832 | rootName: rootName, 833 | fileManager: fileManager 834 | ) { 835 | Directory(name: "from") { 836 | Directory(name: "foo") { 837 | "bap" 838 | Directory(name: "bar") { 839 | "beep" 840 | Directory(name: "baz") { 841 | "boop" 842 | } 843 | } 844 | } 845 | } 846 | Directory(name: "to") 847 | } 848 | .test { 849 | #expect( 850 | $1.pathComponents 851 | == rootUrl.appending(path: rootName).pathComponents 852 | ) 853 | 854 | let from = $1.appending(path: "from") 855 | let to = $1.appending(path: "to") 856 | 857 | try $0.copyRecursively(from: from, to: to) 858 | 859 | let baseUrlLength = to.path().count + 1 860 | let results = $0.listDirectoryRecursively(at: to) 861 | .map { String($0.path().dropFirst(baseUrlLength)) } 862 | .sorted() 863 | let expected = [ 864 | "foo/bap", 865 | "foo/bar/baz/boop", 866 | "foo/bar/beep", 867 | ] 868 | 869 | #expect(expected == results) 870 | } 871 | } 872 | 873 | @Test 874 | func buildAndRemove() throws { 875 | let playground = FileManagerPlayground { 876 | Directory(name: "foo") { 877 | File(name: "bar.txt", string: "baz") 878 | } 879 | } 880 | 881 | let built = try playground.build() 882 | let fileManager = built.0 883 | let builtURL = built.1 884 | 885 | let fileURL = builtURL.appending(path: "foo/bar") 886 | .appendingPathExtension("txt") 887 | 888 | #expect(fileManager.fileExists(atPath: fileURL.path())) 889 | 890 | try playground.remove() 891 | 892 | #expect(!fileManager.fileExists(atPath: builtURL.path())) 893 | } 894 | 895 | @Test 896 | func find() throws { 897 | try FileManagerPlayground { 898 | File(name: "fileA.txt", string: "Test") 899 | File(name: ".hidden.md", string: "Hidden") 900 | File(name: "readme.md", string: "Readme") 901 | Directory(name: "Subdir") { 902 | File(name: "nested.swift", string: "Nested") 903 | } 904 | } 905 | .test { fileManager, rootUrl in 906 | // Non-recursive, skip hidden 907 | let nonRecursive = fileManager.find( 908 | at: rootUrl 909 | ) 910 | #expect(nonRecursive.contains("fileA.txt")) 911 | #expect(nonRecursive.contains("readme.md")) 912 | #expect(!nonRecursive.contains(".hidden.md")) 913 | #expect(!nonRecursive.contains("Subdir/nested.swift")) 914 | 915 | // Recursive, skip hidden 916 | let recursive = fileManager.find( 917 | recursively: true, 918 | at: rootUrl 919 | ) 920 | #expect(recursive.contains("fileA.txt")) 921 | #expect(recursive.contains("readme.md")) 922 | #expect(recursive.contains("Subdir/nested.swift")) 923 | #expect(!recursive.contains(".hidden.md")) 924 | 925 | // Filter by name 926 | let named = fileManager.find( 927 | name: "readme", 928 | recursively: true, 929 | at: rootUrl 930 | ) 931 | #expect(named.count == 1) 932 | #expect(named.first == "readme.md") 933 | 934 | // Filter by extension 935 | let mdFiles = fileManager.find( 936 | extensions: ["md"], 937 | recursively: true, 938 | skipHiddenFiles: false, 939 | at: rootUrl 940 | ) 941 | #expect(mdFiles.contains("readme.md")) 942 | #expect(mdFiles.contains(".hidden.md")) 943 | 944 | // Filter by name and extension 945 | let combo = fileManager.find( 946 | name: "fileA", 947 | extensions: ["txt"], 948 | recursively: true, 949 | at: rootUrl 950 | ) 951 | #expect(combo.contains("fileA.txt")) 952 | } 953 | } 954 | 955 | @Test 956 | func hardLink_whenSourceExists() throws { 957 | try FileManagerPlayground { 958 | File(name: "sourceFile", string: "Hello, hardlink!") 959 | } 960 | .test { 961 | let source = $1.appending(path: "sourceFile") 962 | let destination = $1.appending(path: "destination") 963 | 964 | try $0.hardLink(from: source, to: destination) 965 | 966 | #expect($0.fileExists(at: destination)) 967 | let sourceAttributes = try $0.attributes(at: source) 968 | let destAttributes = try $0.attributes(at: destination) 969 | 970 | let sourceInode = sourceAttributes[.systemFileNumber] as? UInt64 971 | let destInode = destAttributes[.systemFileNumber] as? UInt64 972 | 973 | // Inode numbers should match for a true hard link 974 | #expect(sourceInode != nil && sourceInode == destInode) 975 | 976 | let sourceData = $0.contents(atPath: source.path()) 977 | let destData = $0.contents(atPath: destination.path()) 978 | #expect(sourceData == destData) 979 | } 980 | } 981 | 982 | @Test 983 | func size_whenDirectoryHasNestedFiles() throws { 984 | try FileManagerPlayground { 985 | Directory(name: "folder") { 986 | File(name: "a.txt", string: "12345") 987 | Directory(name: "subfolder") { 988 | File(name: "b.txt", string: "abcde") 989 | } 990 | } 991 | } 992 | .test { fileManager, rootUrl in 993 | let folder = rootUrl.appending(path: "folder") 994 | let fileA = folder.appending(path: "a.txt") 995 | let fileB = folder.appending(path: "subfolder/b.txt") 996 | 997 | let aSize = try fileManager.size(at: fileA) 998 | let bSize = try fileManager.size(at: fileB) 999 | 1000 | #expect(aSize == 5) 1001 | #expect(bSize == 5) 1002 | 1003 | // due to file system differences, this won't be a perfect match 1004 | let expectedSize = 10 // "12345".utf8.count + "abcde".utf8.count 1005 | let reportedSize = try fileManager.size(at: folder) 1006 | 1007 | #expect(reportedSize >= expectedSize) 1008 | // up to 128 KB buffer 1009 | #expect(reportedSize < expectedSize + 128 * 1024) 1010 | } 1011 | } 1012 | 1013 | } 1014 | --------------------------------------------------------------------------------