diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml new file mode 100644 index 0000000..3e935af --- /dev/null +++ b/.github/workflows/continuous_integration.yml @@ -0,0 +1,34 @@ +name: Continuous Integration + +on: + push: + branches: + - main, development + pull_request: + types: [opened, synchronize, reopened] + +jobs: + sonarcloud: + name: Unit-Tests + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Link SwiftLint or install it + run: brew link --overwrite swiftlint || brew install swiftlint + + - name: Set up XCode + run: sudo xcode-select --switch /Applications/Xcode_15.0.app + + - name: Bundle Install + run: bundle install + + - name: Unit tests + run: bundle exec fastlane unit_tests + + - name: Code Coverage + run: bundle exec fastlane coverage + + - name: Lint + run: bundle exec fastlane lint \ No newline at end of file diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml new file mode 100644 index 0000000..6d3256a --- /dev/null +++ b/.github/workflows/prepare_release.yml @@ -0,0 +1,81 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + versionBumpLevel: + description: 'Version bump level (patch, minor, major)' + required: true + type: choice + default: 'patch' + options: + - patch + - minor + - major + +jobs: + build-and-release: + if: github.ref == 'refs/heads/main' + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Link SwiftLint or install it + run: brew link --overwrite swiftlint || brew install swiftlint + + - name: Set up XCode + run: sudo xcode-select --switch /Applications/Xcode_15.0.app + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + - name: Bump version + run: ruby ./scripts/bump_versions.rb ${{ github.event.inputs.versionBumpLevel }} + + - name: Build XCFramework + run: ./scripts/build_framework.sh + + - name: Get new version + id: version + run: echo "VERSION=$(ruby -e 'puts File.read("./IONFilesystemLib.podspec").match(/spec.version.*=.*''(\d+\.\d+\.\d+)''/)[1]')" >> $GITHUB_ENV + + - name: Create new branch + run: | + git switch --create "prepare-new-release-${{ env.VERSION }}" + + - name: Move zip file to root and push changes + run: | + if [ -f IONFilesystemLib.zip ]; then + rm IONFilesystemLib.zip + else + echo "File does not exist." + fi + mv build/IONFilesystemLib.zip . + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add . + git commit -m "chore: Bump version to ${{ env.VERSION }}" + git push origin HEAD:prepare-new-release-${{ env.VERSION }} + + - name: Create pull request + id: create_pr + run: | + gh pr create -B main -H prepare-new-release-${{ env.VERSION }} --title 'Prepare `main` to Release `${{ env.VERSION }}`' --body 'Bumps version to `${{ env.VERSION }}`.
Creates an updated and ready-to-be-released `IONFilesystemLib.zip`.' + PR_NUMBER=$(gh pr view --json number --jq '.number') + echo "PR_NUMBER=${PR_NUMBER}" >> $GITHUB_ENV + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Add label to the pull request + run: | + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/issues/${{ env.PR_NUMBER }}/labels \ + -f "labels[]=release" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release_and_publish.yml b/.github/workflows/release_and_publish.yml new file mode 100644 index 0000000..6032bd8 --- /dev/null +++ b/.github/workflows/release_and_publish.yml @@ -0,0 +1,67 @@ +name: Release and Publish + +on: + pull_request: + types: [closed] + branches: + - 'main' + +jobs: + post-merge: + if: contains(github.event.pull_request.labels.*.name, 'release') && github.event.pull_request.merged == true + runs-on: macos-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Cocoapods + run: gem install cocoapods + + - name: Get new version + id: version + run: echo "VERSION=$(ruby -e 'puts File.read("./IONFilesystemLib.podspec").match(/spec.version.*=.*''(\d+\.\d+\.\d+)''/)[1]')" >> $GITHUB_ENV + + - name: Extract release notes + run: sh scripts/extract_release_notes.sh "${{ env.VERSION }}" >> release_notes.md + + - name: Create Tag + id: create_tag + run: | + # Define the tag name and message + TAG_NAME="${{ env.VERSION }}" + TAG_MESSAGE="Tag for version ${{ env.VERSION }}" + + # Create the tag + git tag -a "$TAG_NAME" -m "$TAG_MESSAGE" + git push origin "$TAG_NAME" + + echo "Tag created: $TAG_NAME" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Release + run: | + # Extract the tag name + TAG_NAME="${{ env.VERSION }}" + RELEASE_NOTES="$(cat release_notes.md)" + + # Create the release using GitHub CLI + gh release create "$TAG_NAME" \ + --title "$TAG_NAME" \ + --notes "$RELEASE_NOTES" \ + "IONFilesystemLib.zip" + + echo "Release created for tag: $TAG_NAME" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy to Cocoapods + run: pod trunk push ./IONFilesystemLib.podspec --allow-warnings + env: + COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} + + - name: Delete Release Branch + run: git push origin --delete prepare-new-release-${{ env.VERSION }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52fe2f7..5268abf 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,5 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output + +build/ \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index f670eca..66bf042 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,6 @@ disabled_rules: - trailing_whitespace +- switch_case_alignment opt_in_rules: - empty_count - empty_string diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae9537..af95e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,3 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Features +- Add read operations, namely `readEntireFile(atURL:withEncoding:)`, `readFileInChunks(atURL:withEncoding:andChunkSize:)`, `listDirectory(atURL:)`, `getItemAttributes(atPath:)` and `getFileURL(atPath: withSearchPath:)`. +- Add write operations, namely `saveFile(atURL:withEncodingAndData:includeIntermediateDirectories:)` and `appendData(_:atURL:includeIntermediateDirectories:)`. +- Add directory operations, namely `createDirectory(atURL:includeIntermediateDirectories:)` and `removeDirectory(atURL:includeIntermediateDirectories:)`. +- Add file management operations, namely `deleteFile(atURL:)`, `renameItem(fromURL:toURL:)` and `copyItem(fromURL:toURL:)`. + +### Chores +- Add dependency management contract file for CocoaPods and Swift Package Manager. +- Add GitHub Actions workflows. +- Create Repository \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index a8e6b78..cb1258d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,20 +22,20 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.1034.0) - aws-sdk-core (3.214.1) + aws-partitions (1.1043.0) + aws-sdk-core (3.217.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.96.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (1.97.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.177.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.179.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.1) + aws-sigv4 (1.11.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) @@ -47,10 +47,10 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) declarative (0.0.20) - digest-crc (0.6.5) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) @@ -171,7 +171,7 @@ GEM http-cookie (1.0.8) domain_name (~> 0.5) httpclient (2.8.3) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) jmespath (1.6.2) json (2.9.1) @@ -180,25 +180,27 @@ GEM logger (1.6.5) mini_magick (4.13.2) mini_mime (1.1.5) - mini_portile2 (2.8.8) minitest (5.25.4) multi_json (1.15.0) multipart-post (2.4.1) nanaimo (0.4.0) naturally (2.2.1) nkf (0.2.0) - nokogiri (1.18.1) - mini_portile2 (~> 2.8.2) + nokogiri (1.18.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-aarch64-linux-gnu) + nokogiri (1.18.2-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.1-arm-linux-gnu) + nokogiri (1.18.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-arm64-darwin) + nokogiri (1.18.2-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.1-x86_64-darwin) + nokogiri (1.18.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-linux-gnu) + nokogiri (1.18.2-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.2-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.2-x86_64-linux-musl) racc (~> 1.4) optparse (0.6.0) os (1.1.4) @@ -258,12 +260,14 @@ GEM xcpretty (~> 0.2, >= 0.0.7) PLATFORMS - aarch64-linux - arm-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl arm64-darwin - x86-linux x86_64-darwin - x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES fastlane diff --git a/OSFilesystemLib.podspec b/IONFilesystemLib.podspec similarity index 53% rename from OSFilesystemLib.podspec rename to IONFilesystemLib.podspec index bf27c46..de4ac84 100644 --- a/OSFilesystemLib.podspec +++ b/IONFilesystemLib.podspec @@ -1,21 +1,21 @@ Pod::Spec.new do |spec| - spec.name = 'OSFilesystemLib' + spec.name = 'IONFilesystemLib' spec.version = '0.0.1' - spec.summary = 'The `OSFilesystemLib` is a template library.' + spec.summary = 'The `IONFilesystemLib` is a template library.' spec.description = <<-DESC - The `OSFilesystemLib` is a template library. + The `IONFilesystemLib` is a template library. - The `OSFilesystemLib` structure provides the main feature of the Library: + The `IONFilesystemLib` structure provides the main feature of the Library: - ping: A simple echo function that returns the input string. DESC - spec.homepage = 'https://github.com/ionic-team/OSFilesystemLib-iOS' + spec.homepage = 'https://github.com/ionic-team/IONFilesystemLib-iOS' spec.license = { :type => 'MIT', :file => 'LICENSE' } spec.author = { 'OutSystems Mobile Ecosystem' => 'rd.mobileecosystem.team@outsystems.com' } - spec.source = { :http => "https://github.com/ionic-team/OSFilesystemLib-iOS/releases/download/#{spec.version}/OSFilesystemLib.zip", :type => "zip" } - spec.vendored_frameworks = "OSFilesystemLib.xcframework" + spec.source = { :http => "https://github.com/ionic-team/IONFilesystemLib-iOS/releases/download/#{spec.version}/IONFilesystemLib.zip", :type => "zip" } + spec.vendored_frameworks = "IONFilesystemLib.xcframework" spec.ios.deployment_target = '14.0' spec.swift_versions = ['5.0', '5.1', '5.2', '5.3', '5.4', '5.5', '5.6', '5.7', '5.8', '5.9'] diff --git a/OSFilesystemLib.xcodeproj/project.pbxproj b/IONFilesystemLib.xcodeproj/project.pbxproj similarity index 64% rename from OSFilesystemLib.xcodeproj/project.pbxproj rename to IONFilesystemLib.xcodeproj/project.pbxproj index 5b259b9..792d4ce 100644 --- a/OSFilesystemLib.xcodeproj/project.pbxproj +++ b/IONFilesystemLib.xcodeproj/project.pbxproj @@ -7,9 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - 756346652C00F21000685AA3 /* OSFilesystemLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 756346612C00F21000685AA3 /* OSFilesystemLib.swift */; }; - 7575CF6A2BFCEE6F008F3FD0 /* OSFilesystemLib.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7575CF612BFCEE6F008F3FD0 /* OSFilesystemLib.framework */; }; - 7575CF802BFCEEEA008F3FD0 /* OSFilesystemLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7575CF7D2BFCEEEA008F3FD0 /* OSFilesystemLib.swift */; }; + 751328D52D3175170031BDD0 /* IONFILEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751328D42D3175170031BDD0 /* IONFILEManager.swift */; }; + 751328DA2D318DBA0031BDD0 /* IONFILEDirectoryManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751328D92D318DBA0031BDD0 /* IONFILEDirectoryManagerTests.swift */; }; + 751328DB2D318E770031BDD0 /* MockFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751328D82D3179430031BDD0 /* MockFileManager.swift */; }; + 7575CF6A2BFCEE6F008F3FD0 /* IONFilesystemLib.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7575CF612BFCEE6F008F3FD0 /* IONFilesystemLib.framework */; }; + 75DA44542D48E435006DF7DE /* IONFILEChunkPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75DA44532D48E435006DF7DE /* IONFILEChunkPublisher.swift */; }; + 75F8380B2D37E42000FCE044 /* IONFILEItemAttributeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F8380A2D37E42000FCE044 /* IONFILEItemAttributeModel.swift */; }; + 75F84D662D39360E00892C89 /* IONFILEManager+Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F84D652D39360E00892C89 /* IONFILEManager+Errors.swift */; }; + 75F84D682D39362F00892C89 /* IONFILEManager+Enums.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F84D672D39362F00892C89 /* IONFILEManager+Enums.swift */; }; + 75FEB2B02D3546D7007C2686 /* IONFILEManager+Protocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB2AF2D3546D7007C2686 /* IONFILEManager+Protocols.swift */; }; + 75FEB2B22D35470C007C2686 /* URL+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB2B12D35470C007C2686 /* URL+Extension.swift */; }; + 75FEB2B42D35479B007C2686 /* IONFILEFileManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB2B32D35479B007C2686 /* IONFILEFileManagerTests.swift */; }; + 75FEB2B72D355F21007C2686 /* file.txt in Resources */ = {isa = PBXBuildFile; fileRef = 75FEB2B62D355F21007C2686 /* file.txt */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -23,10 +32,20 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 756346612C00F21000685AA3 /* OSFilesystemLib.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSFilesystemLib.swift; sourceTree = ""; }; - 7575CF612BFCEE6F008F3FD0 /* OSFilesystemLib.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OSFilesystemLib.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 7575CF692BFCEE6F008F3FD0 /* OSFilesystemLibTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OSFilesystemLibTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 7575CF7D2BFCEEEA008F3FD0 /* OSFilesystemLib.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSFilesystemLib.swift; sourceTree = ""; }; + 751328D42D3175170031BDD0 /* IONFILEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONFILEManager.swift; sourceTree = ""; }; + 751328D82D3179430031BDD0 /* MockFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFileManager.swift; sourceTree = ""; }; + 751328D92D318DBA0031BDD0 /* IONFILEDirectoryManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONFILEDirectoryManagerTests.swift; sourceTree = ""; }; + 754D90BC2D4BD81000AD7FD4 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; + 7575CF612BFCEE6F008F3FD0 /* IONFilesystemLib.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = IONFilesystemLib.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7575CF692BFCEE6F008F3FD0 /* IONFilesystemLibTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IONFilesystemLibTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 75DA44532D48E435006DF7DE /* IONFILEChunkPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONFILEChunkPublisher.swift; sourceTree = ""; }; + 75F8380A2D37E42000FCE044 /* IONFILEItemAttributeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONFILEItemAttributeModel.swift; sourceTree = ""; }; + 75F84D652D39360E00892C89 /* IONFILEManager+Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IONFILEManager+Errors.swift"; sourceTree = ""; }; + 75F84D672D39362F00892C89 /* IONFILEManager+Enums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IONFILEManager+Enums.swift"; sourceTree = ""; }; + 75FEB2AF2D3546D7007C2686 /* IONFILEManager+Protocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IONFILEManager+Protocols.swift"; sourceTree = ""; }; + 75FEB2B12D35470C007C2686 /* URL+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extension.swift"; sourceTree = ""; }; + 75FEB2B32D35479B007C2686 /* IONFILEFileManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IONFILEFileManagerTests.swift; sourceTree = ""; }; + 75FEB2B62D355F21007C2686 /* file.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = file.txt; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -41,7 +60,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7575CF6A2BFCEE6F008F3FD0 /* OSFilesystemLib.framework in Frameworks */, + 7575CF6A2BFCEE6F008F3FD0 /* IONFilesystemLib.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -51,8 +70,9 @@ 7575CF572BFCEE6F008F3FD0 = { isa = PBXGroup; children = ( - 7575CF632BFCEE6F008F3FD0 /* OSFilesystemLib */, - 7575CF6D2BFCEE6F008F3FD0 /* OSFilesystemLibTests */, + 754D90BC2D4BD81000AD7FD4 /* Package.swift */, + 7575CF632BFCEE6F008F3FD0 /* IONFilesystemLib */, + 7575CF6D2BFCEE6F008F3FD0 /* IONFilesystemLibTests */, 7575CF622BFCEE6F008F3FD0 /* Products */, ); sourceTree = ""; @@ -60,26 +80,35 @@ 7575CF622BFCEE6F008F3FD0 /* Products */ = { isa = PBXGroup; children = ( - 7575CF612BFCEE6F008F3FD0 /* OSFilesystemLib.framework */, - 7575CF692BFCEE6F008F3FD0 /* OSFilesystemLibTests.xctest */, + 7575CF612BFCEE6F008F3FD0 /* IONFilesystemLib.framework */, + 7575CF692BFCEE6F008F3FD0 /* IONFilesystemLibTests.xctest */, ); name = Products; sourceTree = ""; }; - 7575CF632BFCEE6F008F3FD0 /* OSFilesystemLib */ = { + 7575CF632BFCEE6F008F3FD0 /* IONFilesystemLib */ = { isa = PBXGroup; children = ( - 756346612C00F21000685AA3 /* OSFilesystemLib.swift */, + 751328D42D3175170031BDD0 /* IONFILEManager.swift */, + 75F84D672D39362F00892C89 /* IONFILEManager+Enums.swift */, + 75F84D652D39360E00892C89 /* IONFILEManager+Errors.swift */, + 75FEB2AF2D3546D7007C2686 /* IONFILEManager+Protocols.swift */, + 75DA44532D48E435006DF7DE /* IONFILEChunkPublisher.swift */, + 75F8380A2D37E42000FCE044 /* IONFILEItemAttributeModel.swift */, + 75FEB2B12D35470C007C2686 /* URL+Extension.swift */, ); - path = OSFilesystemLib; + path = IONFilesystemLib; sourceTree = ""; }; - 7575CF6D2BFCEE6F008F3FD0 /* OSFilesystemLibTests */ = { + 7575CF6D2BFCEE6F008F3FD0 /* IONFilesystemLibTests */ = { isa = PBXGroup; children = ( - 7575CF7D2BFCEEEA008F3FD0 /* OSFilesystemLib.swift */, + 751328D82D3179430031BDD0 /* MockFileManager.swift */, + 751328D92D318DBA0031BDD0 /* IONFILEDirectoryManagerTests.swift */, + 75FEB2B32D35479B007C2686 /* IONFILEFileManagerTests.swift */, + 75FEB2B62D355F21007C2686 /* file.txt */, ); - path = OSFilesystemLibTests; + path = IONFilesystemLibTests; sourceTree = ""; }; /* End PBXGroup section */ @@ -95,9 +124,9 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ - 7575CF602BFCEE6F008F3FD0 /* OSFilesystemLib */ = { + 7575CF602BFCEE6F008F3FD0 /* IONFilesystemLib */ = { isa = PBXNativeTarget; - buildConfigurationList = 7575CF732BFCEE6F008F3FD0 /* Build configuration list for PBXNativeTarget "OSFilesystemLib" */; + buildConfigurationList = 7575CF732BFCEE6F008F3FD0 /* Build configuration list for PBXNativeTarget "IONFilesystemLib" */; buildPhases = ( 7575CF5C2BFCEE6F008F3FD0 /* Headers */, 7575CF5D2BFCEE6F008F3FD0 /* Sources */, @@ -109,14 +138,14 @@ ); dependencies = ( ); - name = OSFilesystemLib; + name = IONFilesystemLib; productName = OSInAppBrowserLib; - productReference = 7575CF612BFCEE6F008F3FD0 /* OSFilesystemLib.framework */; + productReference = 7575CF612BFCEE6F008F3FD0 /* IONFilesystemLib.framework */; productType = "com.apple.product-type.framework"; }; - 7575CF682BFCEE6F008F3FD0 /* OSFilesystemLibTests */ = { + 7575CF682BFCEE6F008F3FD0 /* IONFilesystemLibTests */ = { isa = PBXNativeTarget; - buildConfigurationList = 7575CF762BFCEE6F008F3FD0 /* Build configuration list for PBXNativeTarget "OSFilesystemLibTests" */; + buildConfigurationList = 7575CF762BFCEE6F008F3FD0 /* Build configuration list for PBXNativeTarget "IONFilesystemLibTests" */; buildPhases = ( 7575CF652BFCEE6F008F3FD0 /* Sources */, 7575CF662BFCEE6F008F3FD0 /* Frameworks */, @@ -127,9 +156,9 @@ dependencies = ( 7575CF6C2BFCEE6F008F3FD0 /* PBXTargetDependency */, ); - name = OSFilesystemLibTests; + name = IONFilesystemLibTests; productName = OSInAppBrowserLibTests; - productReference = 7575CF692BFCEE6F008F3FD0 /* OSFilesystemLibTests.xctest */; + productReference = 7575CF692BFCEE6F008F3FD0 /* IONFilesystemLibTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -140,19 +169,19 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1510; - LastUpgradeCheck = 1510; + LastUpgradeCheck = 1600; TargetAttributes = { 7575CF602BFCEE6F008F3FD0 = { CreatedOnToolsVersion = 15.1; - LastSwiftMigration = 1510; + LastSwiftMigration = 1600; }; 7575CF682BFCEE6F008F3FD0 = { CreatedOnToolsVersion = 15.1; - LastSwiftMigration = 1510; + LastSwiftMigration = 1600; }; }; }; - buildConfigurationList = 7575CF5B2BFCEE6F008F3FD0 /* Build configuration list for PBXProject "OSFilesystemLib" */; + buildConfigurationList = 7575CF5B2BFCEE6F008F3FD0 /* Build configuration list for PBXProject "IONFilesystemLib" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; @@ -168,8 +197,8 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 7575CF602BFCEE6F008F3FD0 /* OSFilesystemLib */, - 7575CF682BFCEE6F008F3FD0 /* OSFilesystemLibTests */, + 7575CF602BFCEE6F008F3FD0 /* IONFilesystemLib */, + 7575CF682BFCEE6F008F3FD0 /* IONFilesystemLibTests */, ); }; /* End PBXProject section */ @@ -186,6 +215,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 75FEB2B72D355F21007C2686 /* file.txt in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -217,7 +247,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 756346652C00F21000685AA3 /* OSFilesystemLib.swift in Sources */, + 75F8380B2D37E42000FCE044 /* IONFILEItemAttributeModel.swift in Sources */, + 75DA44542D48E435006DF7DE /* IONFILEChunkPublisher.swift in Sources */, + 75FEB2B02D3546D7007C2686 /* IONFILEManager+Protocols.swift in Sources */, + 75F84D682D39362F00892C89 /* IONFILEManager+Enums.swift in Sources */, + 751328D52D3175170031BDD0 /* IONFILEManager.swift in Sources */, + 75F84D662D39360E00892C89 /* IONFILEManager+Errors.swift in Sources */, + 75FEB2B22D35470C007C2686 /* URL+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -225,7 +261,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7575CF802BFCEEEA008F3FD0 /* OSFilesystemLib.swift in Sources */, + 751328DA2D318DBA0031BDD0 /* IONFILEDirectoryManagerTests.swift in Sources */, + 751328DB2D318E770031BDD0 /* MockFileManager.swift in Sources */, + 75FEB2B42D35479B007C2686 /* IONFILEFileManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -234,7 +272,7 @@ /* Begin PBXTargetDependency section */ 7575CF6C2BFCEE6F008F3FD0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 7575CF602BFCEE6F008F3FD0 /* OSFilesystemLib */; + target = 7575CF602BFCEE6F008F3FD0 /* IONFilesystemLib */; targetProxy = 7575CF6B2BFCEE6F008F3FD0 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -374,8 +412,9 @@ buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -384,16 +423,16 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 0.0.1; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; - PRODUCT_BUNDLE_IDENTIFIER = com.outsystems.rd.inappbrowser.OSInAppBrowserLib; + PRODUCT_BUNDLE_IDENTIFIER = io.ionic.libs.filesystem.FilesystemLib; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -408,8 +447,9 @@ buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -418,16 +458,16 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 0.0.1; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; - PRODUCT_BUNDLE_IDENTIFIER = com.outsystems.rd.inappbrowser.OSInAppBrowserLib; + PRODUCT_BUNDLE_IDENTIFIER = io.ionic.libs.filesystem.FilesystemLib; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -439,14 +479,13 @@ 7575CF772BFCEE6F008F3FD0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.outsystems.rd.inappbrowser.OSInAppBrowserLibTests; + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = io.ionic.libs.filesystem.FilesystemLibTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -461,14 +500,13 @@ 7575CF782BFCEE6F008F3FD0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.outsystems.rd.inappbrowser.OSInAppBrowserLibTests; + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = io.ionic.libs.filesystem.FilesystemLibTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -482,7 +520,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 7575CF5B2BFCEE6F008F3FD0 /* Build configuration list for PBXProject "OSFilesystemLib" */ = { + 7575CF5B2BFCEE6F008F3FD0 /* Build configuration list for PBXProject "IONFilesystemLib" */ = { isa = XCConfigurationList; buildConfigurations = ( 7575CF712BFCEE6F008F3FD0 /* Debug */, @@ -491,7 +529,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 7575CF732BFCEE6F008F3FD0 /* Build configuration list for PBXNativeTarget "OSFilesystemLib" */ = { + 7575CF732BFCEE6F008F3FD0 /* Build configuration list for PBXNativeTarget "IONFilesystemLib" */ = { isa = XCConfigurationList; buildConfigurations = ( 7575CF742BFCEE6F008F3FD0 /* Debug */, @@ -500,7 +538,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 7575CF762BFCEE6F008F3FD0 /* Build configuration list for PBXNativeTarget "OSFilesystemLibTests" */ = { + 7575CF762BFCEE6F008F3FD0 /* Build configuration list for PBXNativeTarget "IONFilesystemLibTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 7575CF772BFCEE6F008F3FD0 /* Debug */, diff --git a/OSFilesystemLib.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/IONFilesystemLib.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from OSFilesystemLib.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to IONFilesystemLib.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/OSFilesystemLib.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/IONFilesystemLib.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from OSFilesystemLib.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to IONFilesystemLib.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/OSFilesystemLib.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/IONFilesystemLib.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 100% rename from OSFilesystemLib.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to IONFilesystemLib.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/OSFilesystemLib.xcodeproj/xcshareddata/xcschemes/OSFilesystemLib.xcscheme b/IONFilesystemLib.xcodeproj/xcshareddata/xcschemes/IONFilesystemLib.xcscheme similarity index 81% rename from OSFilesystemLib.xcodeproj/xcshareddata/xcschemes/OSFilesystemLib.xcscheme rename to IONFilesystemLib.xcodeproj/xcshareddata/xcschemes/IONFilesystemLib.xcscheme index 6c2da31..96eef5a 100644 --- a/OSFilesystemLib.xcodeproj/xcshareddata/xcschemes/OSFilesystemLib.xcscheme +++ b/IONFilesystemLib.xcodeproj/xcshareddata/xcschemes/IONFilesystemLib.xcscheme @@ -15,9 +15,9 @@ + BuildableName = "IONFilesystemLib.framework" + BlueprintName = "IONFilesystemLib" + ReferencedContainer = "container:IONFilesystemLib.xcodeproj"> @@ -35,9 +35,9 @@ + BuildableName = "IONFilesystemLibTests.xctest" + BlueprintName = "IONFilesystemLibTests" + ReferencedContainer = "container:IONFilesystemLib.xcodeproj"> @@ -63,9 +63,9 @@ + BuildableName = "IONFilesystemLib.framework" + BlueprintName = "IONFilesystemLib" + ReferencedContainer = "container:IONFilesystemLib.xcodeproj"> diff --git a/IONFilesystemLib/IONFILEChunkPublisher.swift b/IONFilesystemLib/IONFILEChunkPublisher.swift new file mode 100644 index 0000000..cc435bf --- /dev/null +++ b/IONFilesystemLib/IONFILEChunkPublisher.swift @@ -0,0 +1,80 @@ +import Combine +import Foundation + +public class IONFILEChunkPublisher: Publisher { + public typealias Output = IONFILEEncodingValueMapper + public typealias Failure = Error + + private let url: URL + private let chunkSize: Int + private let encoding: IONFILEEncoding + + init(_ url: URL, _ chunkSize: Int, _ encoding: IONFILEEncoding) { + self.url = url + self.chunkSize = chunkSize + self.encoding = encoding + } + + public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { + let subscription = IONFILEChunkSubscription(url, chunkSize, encoding, subscriber) + subscriber.receive(subscription: subscription) + } +} + +private class IONFILEChunkSubscription: Subscription where S.Input == IONFILEEncodingValueMapper, S.Failure == Error { + private let fileHandle: FileHandle? + private let chunkSize: Int + private let encoding: IONFILEEncoding + private let subscriber: S + private var isCompleted = false + + init(_ url: URL, _ chunkSize: Int, _ encoding: IONFILEEncoding, _ subscriber: S) { + self.fileHandle = try? FileHandle(forReadingFrom: url) + self.chunkSize = chunkSize + self.encoding = encoding + self.subscriber = subscriber + } + + func request(_ demand: Subscribers.Demand) { + guard let fileHandle = fileHandle, !isCompleted else { + return subscriber.receive(completion: .failure(IONFILEChunkPublisherError.notAbleToReadFile)) + } + + while demand > .none { + do { + if let chunk = try fileHandle.read(upToCount: chunkSize), !chunk.isEmpty { + let chunkToEmit: IONFILEEncodingValueMapper + switch encoding { + case .byteBuffer: chunkToEmit = .byteBuffer(value: chunk) + case .string(let encoding): + guard let chunkText = String(data: chunk, encoding: encoding.stringEncoding) else { + throw IONFILEChunkPublisherError.cantEncodeData(usingEncoding: encoding) + } + chunkToEmit = .string(encoding: encoding, value: chunkText) + } + + _ = subscriber.receive(chunkToEmit) + } else { + complete(withValue: .finished) + break + } + } catch { + complete(withValue: .failure(error)) + break + } + } + } + + func cancel() { + fileHandle?.closeFile() + } + + deinit { + fileHandle?.closeFile() + } + + private func complete(withValue value: Subscribers.Completion) { + isCompleted = true + subscriber.receive(completion: value) + } +} diff --git a/IONFilesystemLib/IONFILEItemAttributeModel.swift b/IONFilesystemLib/IONFILEItemAttributeModel.swift new file mode 100644 index 0000000..f2a75c9 --- /dev/null +++ b/IONFilesystemLib/IONFILEItemAttributeModel.swift @@ -0,0 +1,43 @@ +import Foundation + +public enum IONFILEItemType: Encodable { + case directory + case file + + static func create(from fileAttributeType: String?) -> Self { + fileAttributeType == FileAttributeKey.FileTypeDirectoryValue ? .directory : .file + } +} + +public struct IONFILEItemAttributeModel { + private(set) public var creationDateTimestamp: Double + private(set) public var modificationDateTimestamp: Double + private(set) public var size: UInt64 + private(set) public var type: IONFILEItemType +} + +public extension IONFILEItemAttributeModel { + static func create(from attributeDictionary: [FileAttributeKey: Any]) -> IONFILEItemAttributeModel { + let creationDate = attributeDictionary[.creationDate] as? Date + let modificationDate = attributeDictionary[.modificationDate] as? Date + let size = attributeDictionary[.size] as? UInt64 ?? 0 + let type = attributeDictionary[.type] as? String + + return .init( + creationDateTimestamp: creationDate?.millisecondsSinceUnixEpoch ?? 0, + modificationDateTimestamp: modificationDate?.millisecondsSinceUnixEpoch ?? 0, + size: size, + type: .create(from: type) + ) + } +} + +extension Date { + var millisecondsSinceUnixEpoch: Double { + timeIntervalSince1970 * 1000 + } +} + +extension FileAttributeKey { + static var FileTypeDirectoryValue = "NSFileTypeDirectory" +} diff --git a/IONFilesystemLib/IONFILEManager+Enums.swift b/IONFilesystemLib/IONFILEManager+Enums.swift new file mode 100644 index 0000000..a21992e --- /dev/null +++ b/IONFilesystemLib/IONFILEManager+Enums.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum IONFILEEncoding: Equatable { + case byteBuffer + case string(encoding: IONFILEStringEncoding) +} + +public enum IONFILEEncodingValueMapper { + case byteBuffer(value: Data) + case string(encoding: IONFILEStringEncoding, value: String) +} + +public enum IONFILEStringEncoding: String { + case ascii + case utf8 + case utf16 + + var stringEncoding: String.Encoding { + switch self { + case .ascii: .ascii + case .utf8: .utf8 + case .utf16: .utf16 + } + } +} + +public enum IONFILESearchPath { + case directory(type: IONFILEDirectoryType) + case raw +} + +public enum IONFILEDirectoryType { + case cache + case document + case library + case notSyncedLibrary + case temporary +} diff --git a/IONFilesystemLib/IONFILEManager+Errors.swift b/IONFilesystemLib/IONFILEManager+Errors.swift new file mode 100644 index 0000000..3c72626 --- /dev/null +++ b/IONFilesystemLib/IONFILEManager+Errors.swift @@ -0,0 +1,39 @@ +import Foundation + +enum IONFILEDirectoryManagerError: LocalizedError { + case notEmpty + + var errorDescription: String? { + "Folder is not empty." + } +} + +enum IONFILEFileManagerError: LocalizedError, Equatable { + case cantCreateURL(forPath: String) + case cantDecodeData(usingEncoding: IONFILEStringEncoding) + case directoryNotFound(atPath: String) + case fileNotFound(atPath: String) + case missingParentFolder + + var errorDescription: String? { + switch self { + case .cantCreateURL(let path): "Can't create URL for path '\(path)'." + case .cantDecodeData(let encoding): "Can't decode data using encoding .\(encoding.rawValue)." + case .directoryNotFound(let path): "Can't find directory at path '\(path)'." + case .fileNotFound(let path): "Can't find file at path '\(path)'." + case .missingParentFolder: "Parent folder doesn't exist." + } + } +} + +enum IONFILEChunkPublisherError: LocalizedError, Equatable { + case cantEncodeData(usingEncoding: IONFILEStringEncoding) + case notAbleToReadFile + + var errorDescription: String? { + switch self { + case .cantEncodeData(let encoding): "Can't encode data using encoding .\(encoding.rawValue)." + case .notAbleToReadFile: "Can't read file." + } + } +} diff --git a/IONFilesystemLib/IONFILEManager+Protocols.swift b/IONFilesystemLib/IONFILEManager+Protocols.swift new file mode 100644 index 0000000..08dfdc7 --- /dev/null +++ b/IONFilesystemLib/IONFILEManager+Protocols.swift @@ -0,0 +1,19 @@ +import Foundation + +public protocol IONFILEDirectoryManager { + func createDirectory(atURL: URL, includeIntermediateDirectories: Bool) throws + func removeDirectory(atURL: URL, includeIntermediateDirectories: Bool) throws + func listDirectory(atURL: URL) throws -> [URL] +} + +public protocol IONFILEFileManager { + func readEntireFile(atURL: URL, withEncoding: IONFILEEncoding) throws -> IONFILEEncodingValueMapper + func readFileInChunks(atURL: URL, withEncoding: IONFILEEncoding, andChunkSize: Int) throws -> IONFILEChunkPublisher + func getFileURL(atPath: String, withSearchPath: IONFILESearchPath) throws -> URL + func deleteFile(atURL: URL) throws + func saveFile(atURL: URL, withEncodingAndData: IONFILEEncodingValueMapper, includeIntermediateDirectories: Bool) throws + func appendData(_ data: IONFILEEncodingValueMapper, atURL: URL, includeIntermediateDirectories: Bool) throws + func getItemAttributes(atURL: URL) throws -> IONFILEItemAttributeModel + func renameItem(fromURL: URL, toURL: URL) throws + func copyItem(fromURL: URL, toURL: URL) throws +} diff --git a/IONFilesystemLib/IONFILEManager.swift b/IONFilesystemLib/IONFILEManager.swift new file mode 100644 index 0000000..be8061d --- /dev/null +++ b/IONFilesystemLib/IONFILEManager.swift @@ -0,0 +1,244 @@ +import Foundation + +public struct IONFILEManager { + private let fileManager: FileManager + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } +} + +extension IONFILEManager: IONFILEDirectoryManager { + public func createDirectory(atURL pathURL: URL, includeIntermediateDirectories: Bool) throws { + try withSecurityScopedAccess(to: pathURL) { + try fileManager.createDirectory(at: pathURL, withIntermediateDirectories: includeIntermediateDirectories) + } + } + + public func removeDirectory(atURL pathURL: URL, includeIntermediateDirectories: Bool) throws { + try withSecurityScopedAccess(to: pathURL) { + if !includeIntermediateDirectories { + let directoryContents = try listDirectory(atURL: pathURL) + if !directoryContents.isEmpty { + throw IONFILEDirectoryManagerError.notEmpty + } + } + + try fileManager.removeItem(at: pathURL) + } + } + + public func listDirectory(atURL pathURL: URL) throws -> [URL] { + try withSecurityScopedAccess(to: pathURL) { + try fileManager.contentsOfDirectory(at: pathURL, includingPropertiesForKeys: nil) + } + } +} + +extension IONFILEManager: IONFILEFileManager { + public func readEntireFile(atURL fileURL: URL, withEncoding encoding: IONFILEEncoding) throws -> IONFILEEncodingValueMapper { + try withSecurityScopedAccess(to: fileURL) { + let result: IONFILEEncodingValueMapper + switch encoding { + case .byteBuffer: + let fileData = try readFileAsByteBuffer(from: fileURL) + result = .byteBuffer(value: fileData) + case .string(let stringEncoding): + let fileData = try readFileAsString(from: fileURL, using: stringEncoding.stringEncoding) + result = .string(encoding: stringEncoding, value: fileData) + } + + return result + } + } + + public func readFileInChunks(atURL fileURL: URL, withEncoding encoding: IONFILEEncoding, andChunkSize chunkSize: Int) throws -> IONFILEChunkPublisher { + try withSecurityScopedAccess(to: fileURL) { + .init(fileURL, chunkSize, encoding) + } + } + + public func getFileURL(atPath path: String, withSearchPath searchPath: IONFILESearchPath) throws -> URL { + switch searchPath { + case .directory(let type): + try resolveDirectoryURL(forType: type, with: path) + case .raw: + try resolveRawURL(from: path) + } + } + + public func deleteFile(atURL url: URL) throws { + try withSecurityScopedAccess(to: url) { + let path = url.urlPath + guard fileManager.fileExists(atPath: path) else { + throw IONFILEFileManagerError.fileNotFound(atPath: path) + } + + try fileManager.removeItem(at: url) + } + } + + public func saveFile(atURL fileURL: URL, withEncodingAndData encodingMapper: IONFILEEncodingValueMapper, includeIntermediateDirectories: Bool) throws { + try withSecurityScopedAccess(to: fileURL) { + let fileDirectoryURL = fileURL.deletingLastPathComponent() + + if !fileManager.fileExists(atPath: fileDirectoryURL.urlPath) { + if includeIntermediateDirectories { + try createDirectory(atURL: fileDirectoryURL, includeIntermediateDirectories: true) + } else { + throw IONFILEFileManagerError.missingParentFolder + } + } + + switch encodingMapper { + case .byteBuffer(let value): + try value.write(to: fileURL) + case .string(let encoding, let value): + try value.write(to: fileURL, atomically: false, encoding: encoding.stringEncoding) + } + } + } + + public func appendData(_ encodingMapper: IONFILEEncodingValueMapper, atURL url: URL, includeIntermediateDirectories: Bool) throws { + try withSecurityScopedAccess(to: url) { + guard fileManager.fileExists(atPath: url.urlPath) else { + try saveFile(atURL: url, withEncodingAndData: encodingMapper, includeIntermediateDirectories: includeIntermediateDirectories) + return + } + + let dataToAppend: Data + switch encodingMapper { + case .byteBuffer(let value): + dataToAppend = value + case .string(let encoding, let value): + guard let valueData = value.data(using: encoding.stringEncoding) else { + throw IONFILEFileManagerError.cantDecodeData(usingEncoding: encoding) + } + dataToAppend = valueData + } + + let fileHandle = try FileHandle(forWritingTo: url) + try fileHandle.seekToEnd() + try fileHandle.write(contentsOf: dataToAppend) + try fileHandle.close() + } + } + + public func getItemAttributes(atURL url: URL) throws -> IONFILEItemAttributeModel { + try withSecurityScopedAccess(to: url) { + let attributesDictionary = try fileManager.attributesOfItem(atPath: url.urlPath) + return .create(from: attributesDictionary) + } + } + + public func renameItem(fromURL originURL: URL, toURL destinationURL: URL) throws { + try withSecurityScopedAccess(to: originURL) { + try withSecurityScopedAccess(to: destinationURL) { + guard try shouldPerformDualPathOperation(fromURL: originURL, toURL: destinationURL) else { + return + } + try fileManager.moveItem(at: originURL, to: destinationURL) + } + } + } + + public func copyItem(fromURL originURL: URL, toURL destinationURL: URL) throws { + try withSecurityScopedAccess(to: originURL) { + try withSecurityScopedAccess(to: destinationURL) { + guard try shouldPerformDualPathOperation(fromURL: originURL, toURL: destinationURL) else { + return + } + try fileManager.copyItem(at: originURL, to: destinationURL) + } + } + } +} + +private extension IONFILEManager { + func withSecurityScopedAccess(to fileURL: URL, perform operation: () throws -> T) throws -> T { + // Check if the URL requires security-scoped access + let requiresSecurityScope = fileURL.startAccessingSecurityScopedResource() + + // Use defer to ensure we stop accessing the security-scoped resource + // only if we started accessing it + defer { + if requiresSecurityScope { + fileURL.stopAccessingSecurityScopedResource() + } + } + + return try operation() + } + + func readFileAsByteBuffer(from fileURL: URL) throws -> Data { + try Data(contentsOf: fileURL) + } + + func readFileAsString(from fileURL: URL, using stringEncoding: String.Encoding) throws -> String { + try String(contentsOf: fileURL, encoding: stringEncoding) + } + + func resolveDirectoryURL(forType directoryType: IONFILEDirectoryType, with path: String) throws -> URL { + guard let directoryURL = directoryType.fetchURL(using: fileManager) else { + throw IONFILEFileManagerError.directoryNotFound(atPath: path) + } + + return path.isEmpty ? directoryURL : directoryURL.urlWithAppendingPath(path) + } + + func resolveRawURL(from path: String) throws -> URL { + guard let rawURL = URL(string: path) else { + throw IONFILEFileManagerError.cantCreateURL(forPath: path) + } + return rawURL + } + + func shouldPerformDualPathOperation(fromURL originURL: URL, toURL destinationURL: URL) throws -> Bool { + guard originURL != destinationURL else { + return false + } + + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: destinationURL.urlPath, isDirectory: &isDirectory) { + if !isDirectory.boolValue { + try deleteFile(atURL: destinationURL) + } + } + + return true + } +} + +private extension IONFILEDirectoryType { + struct Keys { + static let noCloudPath = "NoCloud" + } + + func fetchURL(using fileManager: FileManager) -> URL? { + switch self { + case .cache: + fetchURL(using: fileManager, forSearchPath: .cachesDirectory) + case .document: + fetchURL(using: fileManager, forSearchPath: .documentDirectory) + case .library: + fetchURL(using: fileManager, forSearchPath: .libraryDirectory) + case .notSyncedLibrary: + fetchNotSyncedLibrary(using: fileManager) + case .temporary: + fileManager.temporaryDirectory + } + } + + private func fetchURL(using fileManager: FileManager, forSearchPath searchPath: FileManager.SearchPathDirectory) -> URL? { + fileManager.urls(for: searchPath, in: .userDomainMask).first + } + + private func fetchNotSyncedLibrary(using fileManager: FileManager) -> URL? { + var url = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first?.urlWithAppendingPath(Keys.noCloudPath) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try? url?.setResourceValues(resourceValues) + + return url + } +} diff --git a/IONFilesystemLib/URL+Extension.swift b/IONFilesystemLib/URL+Extension.swift new file mode 100644 index 0000000..8f07475 --- /dev/null +++ b/IONFilesystemLib/URL+Extension.swift @@ -0,0 +1,19 @@ +import Foundation + +extension URL { + public var urlPath: String { + if #available(iOS 16.0, *) { + path(percentEncoded: false) + } else { + path.removingPercentEncoding ?? path + } + } + + func urlWithAppendingPath(_ path: String) -> URL { + if #available(iOS 16.0, *) { + appending(path: path) + } else { + appendingPathComponent(path) + } + } +} diff --git a/IONFilesystemLibTests/IONFILEDirectoryManagerTests.swift b/IONFilesystemLibTests/IONFILEDirectoryManagerTests.swift new file mode 100644 index 0000000..6f4c8cb --- /dev/null +++ b/IONFilesystemLibTests/IONFILEDirectoryManagerTests.swift @@ -0,0 +1,146 @@ +import XCTest + +@testable import IONFilesystemLib + +final class IONFILEDirectoryManagerTests: XCTestCase { + private var sut: IONFILEManager! + + // MARK: - 'createDirectory' tests + func test_createDirectory_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager() + let testDirectory = URL(filePath: "/test/directory") + let shouldIncludeIntermediateDirectories = false + + // When + try sut.createDirectory(atURL: testDirectory, includeIntermediateDirectories: shouldIncludeIntermediateDirectories) + + // Then + XCTAssertEqual(fileManager.capturedPath, testDirectory) + XCTAssertEqual(fileManager.capturedIntermediateDirectories, shouldIncludeIntermediateDirectories) + } + + func test_createDirectory_butFails_shouldReturnAnError() { + // Given + let error = MockFileManagerError.createDirectoryError + createFileManager(with: error) + let testDirectory = URL(filePath: "/test/directory") + let shouldIncludeIntermediateDirectories = false + + // When + XCTAssertThrowsError( + try sut.createDirectory(atURL: testDirectory, includeIntermediateDirectories: shouldIncludeIntermediateDirectories) + ) { + // Then + XCTAssertEqual($0 as? MockFileManagerError, error) + } + } + + // MARK: - 'removeDirectory' tests + func test_removeDirectory_butFails_shouldReturnAnError() { + let error = MockFileManagerError.deleteDirectoryError + createFileManager(with: error) + let testDirectory = URL(filePath: "/test/directory") + let shouldIncludeIntermediateDirectories = true + + // When + XCTAssertThrowsError( + try sut.removeDirectory(atURL: testDirectory, includeIntermediateDirectories: shouldIncludeIntermediateDirectories) + ) { + // Then + XCTAssertEqual($0 as? MockFileManagerError, error) + } + } + + func test_removeDirectory_includingIntermediateDirectories_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager() + let testDirectory = URL(filePath: "/test/directory") + let shouldIncludeIntermediateDirectories = true + + // When + try sut.removeDirectory(atURL: testDirectory, includeIntermediateDirectories: shouldIncludeIntermediateDirectories) + + // Then + XCTAssertEqual(fileManager.capturedPath, testDirectory) + } + + func test_removeDirectory_excludingIntermediateDirectories_directoryDoesntHaveContent_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager() + let testDirectory = URL(filePath: "/test/directory") + let shouldIncludeIntermediateDirectories = false + + // When + try sut.removeDirectory(atURL: testDirectory, includeIntermediateDirectories: shouldIncludeIntermediateDirectories) + + // Then + XCTAssertEqual(fileManager.capturedPath, testDirectory) + } + + func test_removeDirectory_excludingIntermediateDirectories_directoryHasContent_shouldReturnAnError() { + createFileManager(shouldDirectoryHaveContent: true) + let testDirectory = URL(filePath: "/test/directory") + let shouldIncludeIntermediateDirectories = false + + // When + XCTAssertThrowsError( + try sut.removeDirectory(atURL: testDirectory, includeIntermediateDirectories: shouldIncludeIntermediateDirectories) + ) { + // Then + XCTAssertEqual($0 as? IONFILEDirectoryManagerError, .notEmpty) + } + } + + // MARK: - 'listDirectory' tests + func test_listDirectory_withNoContent_shouldReturnEmptyArray() throws { + // Given + let fileManager = createFileManager() + let testDirectory = URL(filePath: "/test/directory") + + // When + let directoryContent = try sut.listDirectory(atURL: testDirectory) + + // Then + XCTAssertEqual(fileManager.capturedPath, testDirectory) + XCTAssertTrue(directoryContent.isEmpty) + } + + // MARK: - 'listDirectory' tests + func test_listDirectory_withContent_shouldReturnNotEmptyArray() throws { + // Given + let fileManager = createFileManager(shouldDirectoryHaveContent: true) + let testDirectory = URL(filePath: "/test/directory") + + // When + let directoryContent = try sut.listDirectory(atURL: testDirectory) + + // Then + XCTAssertEqual(fileManager.capturedPath, testDirectory) + XCTAssertEqual(directoryContent, [testDirectory]) + } + + func test_listDirectory_butFails_shouldReturnAnError() { + // Given + let error = MockFileManagerError.readDirectoryError + createFileManager(with: error) + let testDirectory = URL(filePath: "/test/directory") + + // When + XCTAssertThrowsError( + try sut.listDirectory(atURL: testDirectory) + ) { + // Then + XCTAssertEqual($0 as? MockFileManagerError, error) + } + } +} + +private extension IONFILEDirectoryManagerTests { + @discardableResult func createFileManager(with error: MockFileManagerError? = nil, shouldDirectoryHaveContent: Bool = false) -> MockFileManager { + let fileManager = MockFileManager(error: error, shouldDirectoryHaveContent: shouldDirectoryHaveContent) + sut = IONFILEManager(fileManager: fileManager) + + return fileManager + } +} diff --git a/IONFilesystemLibTests/IONFILEFileManagerTests.swift b/IONFilesystemLibTests/IONFILEFileManagerTests.swift new file mode 100644 index 0000000..438a0da --- /dev/null +++ b/IONFilesystemLibTests/IONFILEFileManagerTests.swift @@ -0,0 +1,881 @@ +import Combine +import XCTest + +@testable import IONFilesystemLib + +final class IONFILEFileManagerTests: XCTestCase { + private var sut: IONFILEManager! + private var cancellables: Set! + + override func setUp() { + cancellables = .init() + } + + override func tearDown() { + cancellables = nil + sut = nil + } +} + +// MARK: - 'readEntireFile` tests +extension IONFILEFileManagerTests { + func test_readEntireFile_withStringEncoding_returnsContentSuccessfully() throws { + // Given + createFileManager() + + // When + let fileContent = try fetchEntireContent( + forFile: (Configuration.fileName, Configuration.fileExtension), withEncoding: .string(encoding: .utf8) + ) + + // Then + XCTAssertEqual(fileContent, Configuration.fileContent) + } + + func test_readEntireFile_withByteBufferEncoding_returnsContentSuccessfully() throws { + // Given + createFileManager() + + // When + let fileContent = try fetchEntireContent( + forFile: (Configuration.fileName, Configuration.fileExtension), withEncoding: .byteBuffer + ) + + // Then + XCTAssertEqual(fileContent, Configuration.fileContent) + } + + func test_readEntireFile_thatDoesntExist_returnsError() throws { + // Given + createFileManager() + let fileURL: URL = try XCTUnwrap(.init(string: "/file/directory")) + + // When and Then + XCTAssertThrowsError(try fetchEntireContent(forURL: fileURL, withEncoding: .string(encoding: .utf8))) + } +} + +// MARK: - 'readFileInChunks' tests +extension IONFILEFileManagerTests { + func test_readFileInChunks_withStringEncoding_returnsContentSuccessfully() throws { + // Given + createFileManager() + + // When + let fileContent = try fetchChunkedContent( + forFile: (Configuration.fileName, Configuration.fileExtension), withEncoding: .string(encoding: .utf8) + ) + + // Then + XCTAssertEqual(fileContent, Configuration.fileContent) + } + + func test_readFileInChunks_withByteBufferEncoding_returnsContentSuccessfully() throws { + // Given + createFileManager() + + // When + let fileContent = try fetchChunkedContent( + forFile: (Configuration.fileName, Configuration.fileExtension), withEncoding: .byteBuffer + ) + + // Then + XCTAssertEqual(fileContent, Configuration.fileContent) + } + + func test_readFileInChunks_notAbleToReadFile_returnsError() { + // Given + createFileManager() + + // When + XCTAssertThrowsError(try fetchChunkedContent( + forFile: (Configuration.fileName, Configuration.fileExtension), withEncoding: .string(encoding: .utf8), forceURLError: true + )) { + // Then + XCTAssertEqual($0 as? IONFILEChunkPublisherError, .notAbleToReadFile) + } + } + + func test_readFileInChunks_thatDoesntExist_returnsError() throws { + // Given + createFileManager() + let fileURL: URL = try XCTUnwrap(.init(string: "/file/directory")) + + // When and Then + XCTAssertThrowsError(try fetchChunkedContent(forURL: fileURL, withEncoding: .string(encoding: .utf8))) + } +} + +// MARK: - 'getFileURL' tests +extension IONFILEFileManagerTests { + func test_getFileURL_fromDirectorySearchPath_containingSingleFile_returnsFileSuccessfully() throws { + // Given + let fileURL: URL = try XCTUnwrap(.init(string: "/file/directory")) + let fileManager = createFileManager(urlsWithinDirectory: [fileURL]) + let filePath = "/test/directory" + let directoryType = IONFILEDirectoryType.cache + + // When + let returnedURL = try sut.getFileURL(atPath: filePath, withSearchPath: .directory(type: directoryType)) + + // Then + XCTAssertEqual(fileManager.capturedSearchPathDirectory, .cachesDirectory) + XCTAssertEqual(fileURL.appending(path: filePath), returnedURL) + } + + func test_getFileURL_fromDirectorySearchPath_containingMultipleFiles_returnsFirstFileSuccessfully() throws { + // Given + let fileURL: URL = try XCTUnwrap(.init(string: "/file/directory")) + let ignoredFileURL: URL = try XCTUnwrap(.init(string: "another_file/directory")) + let fileManager = createFileManager(urlsWithinDirectory: [fileURL, ignoredFileURL]) + let filePath = "/test/directory" + let directoryType = IONFILEDirectoryType.cache + + // When + let returnedURL = try sut.getFileURL(atPath: filePath, withSearchPath: .directory(type: directoryType)) + + // Then + XCTAssertEqual(fileManager.capturedSearchPathDirectory, .cachesDirectory) + XCTAssertEqual(fileURL.appending(path: filePath), returnedURL) + } + + func test_getFileURL_fromDocumentDirectorySearchPath_returnsFileSuccessfully() throws { + // Given + let fileURL: URL = try XCTUnwrap(.init(string: "/file/directory")) + let fileManager = createFileManager(urlsWithinDirectory: [fileURL]) + let filePath = "/test/directory" + let directoryType = IONFILEDirectoryType.document + + // When + let returnedURL = try sut.getFileURL(atPath: filePath, withSearchPath: .directory(type: directoryType)) + + // Then + XCTAssertEqual(fileManager.capturedSearchPathDirectory, .documentDirectory) + XCTAssertEqual(fileURL.appending(path: filePath), returnedURL) + } + + func test_getFileURL_fromLibraryDirectorySearchPath_returnsFileSuccessfully() throws { + // Given + let fileURL: URL = try XCTUnwrap(.init(string: "/file/directory")) + let fileManager = createFileManager(urlsWithinDirectory: [fileURL]) + let filePath = "/test/directory" + let directoryType = IONFILEDirectoryType.library + + // When + let returnedURL = try sut.getFileURL(atPath: filePath, withSearchPath: .directory(type: directoryType)) + + // Then + XCTAssertEqual(fileManager.capturedSearchPathDirectory, .libraryDirectory) + XCTAssertEqual(fileURL.appending(path: filePath), returnedURL) + } + + func test_getFileURL_fromNotSyncedLibraryDirectorySearchPath_returnsFileSuccessfully() throws { + // Given + let fileURL: URL = try XCTUnwrap(.init(string: "/file/directory")) + let fileManager = createFileManager(urlsWithinDirectory: [fileURL]) + let filePath = "/test/directory" + let directoryType = IONFILEDirectoryType.notSyncedLibrary + + // When + let returnedURL = try sut.getFileURL(atPath: filePath, withSearchPath: .directory(type: directoryType)) + + // Then + XCTAssertEqual(fileManager.capturedSearchPathDirectory, .libraryDirectory) + XCTAssertEqual(fileURL.appending(path: "NoCloud").appending(path: filePath), returnedURL) + } + + func test_getFileURL_fromTemporaryDirectorySearchPath_returnsFileSuccessfully() throws { + // Given + let parentFolderURL: URL = try XCTUnwrap(.init(string: "/file")) + let fileURL: URL = parentFolderURL.appending(path: "/directory") + let fileManager = createFileManager(urlsWithinDirectory: [fileURL], mockTemporaryDirectory: parentFolderURL) + let filePath = "/test/directory" + let directoryType = IONFILEDirectoryType.temporary + + // When + let returnedURL = try sut.getFileURL(atPath: filePath, withSearchPath: .directory(type: directoryType)) + + // Then + XCTAssertEqual(fileManager.temporaryDirectory, parentFolderURL) + XCTAssertEqual(parentFolderURL.appending(path: filePath), returnedURL) + } + + func test_getFileURL_fromDirectorySearchPath_containingNoFiles_returnsError() { + // Given + createFileManager() + let filePath = "/test/directory" + let directoryType = IONFILEDirectoryType.cache + + // When + XCTAssertThrowsError(try sut.getFileURL(atPath: filePath, withSearchPath: .directory(type: directoryType))) { + // Then + XCTAssertEqual($0 as? IONFILEFileManagerError, .directoryNotFound(atPath: filePath)) + } + } + + func test_getFileURL_fromDirectorySearchPath_withNoPath_returnsFileSuccessfully() throws { + // Given + let fileURL: URL = try XCTUnwrap(.init(string: "/file/directory")) + let fileManager = createFileManager(urlsWithinDirectory: [fileURL]) + let emptyFilePath = "" + let directoryType = IONFILEDirectoryType.cache + + // When + let returnedURL = try sut.getFileURL(atPath: emptyFilePath, withSearchPath: .directory(type: directoryType)) + + // Then + XCTAssertEqual(fileManager.capturedSearchPathDirectory, .cachesDirectory) + XCTAssertEqual(fileURL, returnedURL) + } + + func test_getFileURL_rawFile_returnsFileSuccessfully() throws { + // Given + createFileManager() + let filePath = "/test/directory" + + // When + let returnedURL = try sut.getFileURL(atPath: filePath, withSearchPath: .raw) + + // Then + XCTAssertEqual(filePath, returnedURL.path()) + } + + func test_getFileURL_rawFile_fromInvalidPath_returnsError() { + // Given + createFileManager() + let emptyFilePath = "" + + // When + XCTAssertThrowsError(try sut.getFileURL(atPath: emptyFilePath, withSearchPath: .raw)) { + // Then + XCTAssertEqual($0 as? IONFILEFileManagerError, .cantCreateURL(forPath: emptyFilePath)) + } + } +} + +// MARK: - 'deleteFile' tests +extension IONFILEFileManagerTests { + func test_deleteFile_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager() + let filePath = URL(filePath: "/test/directory") + + // When + try sut.deleteFile(atURL: filePath) + + // Then + XCTAssertEqual(fileManager.capturedPath, filePath) + } + + func test_deleteFile_thatDoesntExist_shouldReturnError() { + // Given + createFileManager(fileExists: false) + let filePath = URL(filePath: "/test/directory") + + // When + XCTAssertThrowsError(try sut.deleteFile(atURL: filePath)) { + // Then + XCTAssertEqual($0 as? IONFILEFileManagerError, .fileNotFound(atPath: filePath.urlPath)) + } + } + + func test_deleteFile_thatFailsWhileDeleting_shouldReturnError() { + // Given + let error = MockFileManagerError.deleteFileError + createFileManager(error: error) + let filePath = URL(filePath: "/test/directory") + + // When + XCTAssertThrowsError(try sut.deleteFile(atURL: filePath)) { + // Then + XCTAssertEqual($0 as? MockFileManagerError, error) + } + } +} + +// MARK: - 'saveFile' tests +extension IONFILEFileManagerTests { + func test_saveFile_withStringEncoding_savesFileSuccessfullyAndReturnsItsURL() throws { + // Given + let fileManager = createFileManager() + let fileURL = try XCTUnwrap(fetchConfigurationFile()) + .deletingLastPathComponent() + .appending(path: "\(Configuration.newFileName).\(Configuration.fileExtension)") + let stringEncoding = IONFILEStringEncoding.ascii + let contentToSave = Configuration.stringEncodedFileContent + let shouldIncludeIntermediateDirectories = false + + // When + try sut.saveFile( + atURL: fileURL, + withEncodingAndData: .string(encoding: stringEncoding, value: contentToSave), + includeIntermediateDirectories: shouldIncludeIntermediateDirectories + ) + + // Then + XCTAssertEqual(fileManager.capturedIntermediateDirectories, shouldIncludeIntermediateDirectories) + + let savedFileContent = try fetchEntireContent(forURL: fileURL, withEncoding: .string(encoding: stringEncoding)) + XCTAssertEqual(savedFileContent, contentToSave) + + try sut.deleteFile(atURL: fileURL) // keep things clean by deleting created file + } + + func test_saveFile_withByteBufferEncoding_savesFileSuccessfullyAndReturnsItsURL() throws { + // Given + let fileManager = createFileManager() + let fileURL = try XCTUnwrap(fetchConfigurationFile()) + .deletingLastPathComponent() + .appending(path: "\(Configuration.newFileName).\(Configuration.fileExtension)") + let contentToSave = Configuration.byteBufferEncodedFileContent + let contentToSaveData = try XCTUnwrap(contentToSave.data(using: .utf8)) + let shouldIncludeIntermediateDirectories = false + + // When + try sut.saveFile( + atURL: fileURL, + withEncodingAndData: .byteBuffer(value: contentToSaveData), + includeIntermediateDirectories: shouldIncludeIntermediateDirectories + ) + + // Then + XCTAssertEqual(fileManager.capturedIntermediateDirectories, shouldIncludeIntermediateDirectories) + + let savedFileContent = try fetchEntireContent(forURL: fileURL, withEncoding: .byteBuffer) + XCTAssertEqual(savedFileContent, contentToSave) + + try sut.deleteFile(atURL: fileURL) // keep things clean by deleting created file + } + + func test_saveFile_parentFolderMissing_shouldCreateIt_savesFileSuccessfullyAndReturnsItsURL() throws { + // Given + let fileManager = createFileManager(fileExists: false) + let parentFolderURL = try XCTUnwrap(fetchConfigurationFile()) + .deletingLastPathComponent() + let fileURL = parentFolderURL + .appending(path: "\(Configuration.newFileName).\(Configuration.fileExtension)") + let stringEncoding = IONFILEStringEncoding.ascii + let contentToSave = Configuration.stringEncodedFileContent + let shouldIncludeIntermediateDirectories = true + + // When + try sut.saveFile( + atURL: fileURL, + withEncodingAndData: .string(encoding: stringEncoding, value: contentToSave), + includeIntermediateDirectories: shouldIncludeIntermediateDirectories + ) + + // Then + XCTAssertEqual(fileManager.capturedIntermediateDirectories, shouldIncludeIntermediateDirectories) + XCTAssertEqual(fileManager.capturedPath, parentFolderURL) + + let savedFileContent = try fetchEntireContent(forURL: fileURL, withEncoding: .string(encoding: stringEncoding)) + XCTAssertEqual(savedFileContent, contentToSave) + + fileManager.fileExists = true + try sut.deleteFile(atURL: fileURL) // keep things clean by deleting created file + } + + func test_saveFile_parentFolderMissing_shouldntCreateIt_returnsError() throws { + // Given + createFileManager(fileExists: false) + let parentFolderURL = try XCTUnwrap(fetchConfigurationFile()) + .deletingLastPathComponent() + let fileURL = parentFolderURL + .appending(path: "\(Configuration.newFileName).\(Configuration.fileExtension)") + let stringEncoding = IONFILEStringEncoding.ascii + let contentToSave = Configuration.stringEncodedFileContent + let shouldIncludeIntermediateDirectories = false + + // When + XCTAssertThrowsError(try sut.saveFile( + atURL: fileURL, + withEncodingAndData: .string(encoding: stringEncoding, value: contentToSave), + includeIntermediateDirectories: shouldIncludeIntermediateDirectories) + ) { + // Then + XCTAssertEqual($0 as? IONFILEFileManagerError, .missingParentFolder) + } + } +} + +// MARK: - 'appendData' tests +extension IONFILEFileManagerTests { + func test_appendData_withStringEncoding_savesFileSuccessfully() throws { + // Given + createFileManager() + let fileURL = try XCTUnwrap(fetchConfigurationFile()) + let stringEncoding = IONFILEStringEncoding.ascii + let contentToAdd = Configuration.fileExtendedContent + + // When + try sut.appendData( + .string(encoding: stringEncoding, value: contentToAdd), + atURL: fileURL, + includeIntermediateDirectories: false + ) + + // Then + let savedFileContent = try fetchEntireContent(forURL: fileURL, withEncoding: .string(encoding: stringEncoding)) + XCTAssertEqual(savedFileContent, Configuration.fileContent + contentToAdd) + + try sut.saveFile( // keep things clean by resetting file + atURL: fileURL, + withEncodingAndData: .string(encoding: stringEncoding, value: Configuration.fileContent), + includeIntermediateDirectories: false + ) + } + + func test_appendData_withByteBufferEncoding_savesFileSuccessfully() throws { + // Given + createFileManager() + let fileURL = try XCTUnwrap(fetchConfigurationFile()) + let contentToAdd = Configuration.byteBufferEncodedFileContent + let contentToAddData = try XCTUnwrap(contentToAdd.data(using: .utf8)) + + // When + try sut.appendData( + .byteBuffer(value: contentToAddData), + atURL: fileURL, + includeIntermediateDirectories: false + ) + + // Then + let savedFileContent = try fetchEntireContent(forURL: fileURL, withEncoding: .byteBuffer) + XCTAssertEqual(savedFileContent, Configuration.fileContent + contentToAdd) + + try sut.saveFile( // keep things clean by resetting file + atURL: fileURL, + withEncodingAndData: .string(encoding: .ascii, value: Configuration.fileContent), + includeIntermediateDirectories: false + ) + } + + func test_appendData_fileDoesntExist_createsNewFileSuccessfully() throws { + // Given + let fileManager = createFileManager(fileExists: false) + let parentFolderURL = try XCTUnwrap(fetchConfigurationFile()) + .deletingLastPathComponent() + let fileURL = parentFolderURL + .appending(path: "\(Configuration.newFileName).\(Configuration.fileExtension)") + let stringEncoding = IONFILEStringEncoding.ascii + let contentToAdd = Configuration.stringEncodedFileContent + let shouldIncludeIntermediateDirectories = true + + // When + try sut.appendData( + .string(encoding: stringEncoding, value: contentToAdd), + atURL: fileURL, + includeIntermediateDirectories: shouldIncludeIntermediateDirectories + ) + + XCTAssertEqual(fileManager.capturedPath, parentFolderURL) + XCTAssertEqual(fileManager.capturedIntermediateDirectories, shouldIncludeIntermediateDirectories) + + // Then + let savedFileContent = try fetchEntireContent(forURL: fileURL, withEncoding: .string(encoding: stringEncoding)) + + XCTAssertEqual(savedFileContent, contentToAdd) + + fileManager.fileExists = true + try sut.deleteFile(atURL: fileURL) // keep things clean by deleting created file + } + + func test_appendData_withStringEncoding_textCantBeDecoded_returnsError() throws { + // Given + createFileManager() + let fileURL = try XCTUnwrap(fetchConfigurationFile()) + let stringEncoding = IONFILEStringEncoding.ascii + let contentToAdd = Configuration.emojiContent // ASCII can't represent emoji so the conversion will fail. + + // When + XCTAssertThrowsError(try sut.appendData( + .string(encoding: stringEncoding, value: contentToAdd), + atURL: fileURL, + includeIntermediateDirectories: false) + ) { + // Then + XCTAssertEqual($0 as? IONFILEFileManagerError, .cantDecodeData(usingEncoding: stringEncoding)) + } + } +} + +// MARK: - 'getItemAttributes' tests +extension IONFILEFileManagerTests { + func test_getItemAttributes_forFile_returnsFileAttributeModelSuccessfully() throws { + // Given + let currentDate = Date() + let createHourDifference = 2 + let modificationHourDifference = 1 + let fileSize: UInt64 = 128 + let fileAttributes = Configuration.fileAttributes( + consideringDate: currentDate, andDifference: (createHourDifference, modificationHourDifference), size: fileSize, isDirectoryType: false + ) + let fileManager = createFileManager(fileAttributes: fileAttributes) + let testDirectory = URL(filePath: "/test/directory") + + // When + let fileAttributesModel = try sut.getItemAttributes(atURL: testDirectory) + + // Then + XCTAssertEqual(fileManager.capturedPath, testDirectory) + XCTAssertEqual(fileAttributesModel.creationDateTimestamp, applyHourDifference( + createHourDifference, toTimestamp: currentDate.millisecondsSinceUnixEpoch + )) + XCTAssertEqual(fileAttributesModel.modificationDateTimestamp, applyHourDifference( + modificationHourDifference, toTimestamp: currentDate.millisecondsSinceUnixEpoch + )) + XCTAssertEqual(fileAttributesModel.size, fileSize) + XCTAssertEqual(fileAttributesModel.type, .file) + } + + func test_getItemAttributes_omittingValues_returnsFileAttributeModelSuccessfully() throws { + // Given + let fileAttributes = Configuration.fileAttributes( + isDirectoryType: false + ) + let fileManager = createFileManager(fileAttributes: fileAttributes) + let testDirectory = URL(filePath: "/test/directory") + + // When + let fileAttributesModel = try sut.getItemAttributes(atURL: testDirectory) + + // Then + XCTAssertEqual(fileManager.capturedPath, testDirectory) + XCTAssertEqual(fileAttributesModel.creationDateTimestamp, 0) + XCTAssertEqual(fileAttributesModel.modificationDateTimestamp, 0) + XCTAssertEqual(fileAttributesModel.size, 0) + XCTAssertEqual(fileAttributesModel.type, .file) + } + + func test_getItemAttributes_forDirectory_returnsFileAttributeModelSuccessfully() throws { + // Given + let currentDate = Date() + let createHourDifference = 2 + let modificationHourDifference = 1 + let fileSize: UInt64 = 128 + let fileAttributes = Configuration.fileAttributes( + consideringDate: currentDate, andDifference: (createHourDifference, modificationHourDifference), size: fileSize, isDirectoryType: true + ) + let fileManager = createFileManager(fileAttributes: fileAttributes) + let testDirectory = URL(filePath: "/test/directory") + + // When + let fileAttributesModel = try sut.getItemAttributes(atURL: testDirectory) + + // Then + XCTAssertEqual(fileManager.capturedPath, testDirectory) + XCTAssertEqual(fileAttributesModel.creationDateTimestamp, applyHourDifference( + createHourDifference, toTimestamp: currentDate.millisecondsSinceUnixEpoch + )) + XCTAssertEqual(fileAttributesModel.modificationDateTimestamp, applyHourDifference( + modificationHourDifference, toTimestamp: currentDate.millisecondsSinceUnixEpoch + )) + XCTAssertEqual(fileAttributesModel.size, fileSize) + XCTAssertEqual(fileAttributesModel.type, .directory) + } + + func test_getItemAttributes_errorWhileRetrieving_returnsError() { + // Given + let error = MockFileManagerError.itemAttributesError + let currentDate = Date() + let createHourDifference = 2 + let modificationHourDifference = 1 + let fileSize: UInt64 = 128 + let fileAttributes = Configuration.fileAttributes( + consideringDate: currentDate, andDifference: (createHourDifference, modificationHourDifference), size: fileSize, isDirectoryType: false + ) + createFileManager(error: error, fileAttributes: fileAttributes) + let testDirectory = URL(filePath: "/test/directory") + + // When + XCTAssertThrowsError(try sut.getItemAttributes(atURL: testDirectory)) { + // Then + XCTAssertEqual($0 as? MockFileManagerError, error) + } + } +} + +// MARK: - 'renameItem' tests +extension IONFILEFileManagerTests { + func test_renameItem_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager(fileExists: false) + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/destination") + + // When + try sut.renameItem(fromURL: originPath, toURL: destinationPath) + + // Then + XCTAssertEqual(fileManager.capturedOriginPath, originPath) + XCTAssertEqual(fileManager.capturedDestinationPath, destinationPath) + } + + func test_renameItem_sameOriginAndDestination_shouldDoNothing() throws { + // Given + let fileManager = createFileManager(fileExists: false) + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/origin") + + // When + try sut.renameItem(fromURL: originPath, toURL: destinationPath) + + // Then + XCTAssertNil(fileManager.capturedOriginPath) + XCTAssertNil(fileManager.capturedDestinationPath) + } + + func test_renameDirectory_alreadyExisting_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager() + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/destination") + + // When + try sut.renameItem(fromURL: originPath, toURL: destinationPath) + + // Then + XCTAssertEqual(fileManager.capturedOriginPath, originPath) + XCTAssertEqual(fileManager.capturedDestinationPath, destinationPath) + } + + func test_renameFile_alreadyExisting_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager(shouldBeDirectory: false) + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/destination") + + // When + try sut.renameItem(fromURL: originPath, toURL: destinationPath) + + // Then + XCTAssertEqual(fileManager.capturedPath, destinationPath) + XCTAssertEqual(fileManager.capturedOriginPath, originPath) + XCTAssertEqual(fileManager.capturedDestinationPath, destinationPath) + } + + func test_renameFile_copyFails_returnsError() throws { + // Given + let error = MockFileManagerError.moveFileError + createFileManager(error: error) + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/destination") + + // When + XCTAssertThrowsError(try sut.renameItem(fromURL: originPath, toURL: destinationPath)) { + // Then + XCTAssertEqual($0 as? MockFileManagerError, error) + } + } +} + +// MARK: - 'copyItem' tests +extension IONFILEFileManagerTests { + func test_copyItem_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager(fileExists: false) + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/destination") + + // When + try sut.copyItem(fromURL: originPath, toURL: destinationPath) + + // Then + XCTAssertEqual(fileManager.capturedOriginPath, originPath) + XCTAssertEqual(fileManager.capturedDestinationPath, destinationPath) + } + + func test_copyItem_sameOriginAndDestination_shouldDoNothing() throws { + // Given + let fileManager = createFileManager(fileExists: false) + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/origin") + + // When + try sut.copyItem(fromURL: originPath, toURL: destinationPath) + + // Then + XCTAssertNil(fileManager.capturedOriginPath) + XCTAssertNil(fileManager.capturedDestinationPath) + + } + + func test_copyDirectory_alreadyExisting_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager() + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/destination") + + // When + try sut.copyItem(fromURL: originPath, toURL: destinationPath) + + // Then + XCTAssertEqual(fileManager.capturedOriginPath, originPath) + XCTAssertEqual(fileManager.capturedDestinationPath, destinationPath) + } + + func test_copyFile_alreadyExisting_shouldBeSuccessful() throws { + // Given + let fileManager = createFileManager(shouldBeDirectory: false) + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/destination") + + // When + try sut.copyItem(fromURL: originPath, toURL: destinationPath) + + // Then + XCTAssertEqual(fileManager.capturedPath, destinationPath) + XCTAssertEqual(fileManager.capturedOriginPath, originPath) + XCTAssertEqual(fileManager.capturedDestinationPath, destinationPath) + } + + func test_copyFile_copyFails_returnsError() throws { + // Given + let error = MockFileManagerError.copyFileError + createFileManager(error: error) + let originPath = URL(filePath: "/test/origin") + let destinationPath = URL(filePath: "/test/destination") + + // When + XCTAssertThrowsError(try sut.copyItem(fromURL: originPath, toURL: destinationPath)) { + // Then + XCTAssertEqual($0 as? MockFileManagerError, error) + } + } +} + +private extension IONFILEFileManagerTests { + struct Configuration { + static let fileName = "file" + static let newFileName = "new_file" + static let fileExtension = "txt" + static let fileContent = "Hello, world!" + static let stringEncodedFileContent = "Hello, string-encoded world!" + static let byteBufferEncodedFileContent = "Hello, byte buffer-encoded world!" + static let fileExtendedContent = " How are you?" + static let emojiContent = "🙃" + + static func fileAttributes( + consideringDate date: Date? = nil, + andDifference dateDifference: (creation: Int, modification: Int)? = nil, + size: UInt64? = nil, + isDirectoryType: Bool + ) -> [FileAttributeKey: Any] { + var result: [FileAttributeKey: Any] = [.type: isDirectoryType ? FileAttributeKey.FileTypeDirectoryValue : Configuration.fileName] + + if let date { + let removeDifferenceToDate: (Int) -> Date? = { + Calendar.current.date(byAdding: .hour, value: $0, to: date) + } + if let difference = dateDifference?.creation { + result[.creationDate] = removeDifferenceToDate(-difference) + } + if let difference = dateDifference?.modification { + result[.modificationDate] = removeDifferenceToDate(-difference) + } + } + + if let size { + result[.size] = size + } + + return result + } + } + + @discardableResult + func createFileManager( + error: MockFileManagerError? = nil, + urlsWithinDirectory: [URL] = [], + fileExists: Bool = true, + fileAttributes: [FileAttributeKey: Any] = [:], + shouldBeDirectory: ObjCBool = true, + mockTemporaryDirectory: URL? = nil + ) -> MockFileManager { + let fileManager = MockFileManager( + error: error, + urlsWithinDirectory: urlsWithinDirectory, + fileExists: fileExists, + fileAttributes: fileAttributes, + shouldBeDirectory: shouldBeDirectory, + mockTemporaryDirectory: mockTemporaryDirectory + ) + sut = IONFILEManager(fileManager: fileManager) + + return fileManager + } + + func fetchEntireContent(forFile file: (name: String, extension: String), withEncoding encoding: IONFILEEncoding) throws -> String { + let fileURL = try XCTUnwrap(Bundle(for: type(of: self)).url(forResource: file.name, withExtension: file.extension)) + return try fetchEntireContent(forURL: fileURL, withEncoding: encoding) + } + + @discardableResult + func fetchEntireContent(forURL fileURL: URL, withEncoding encoding: IONFILEEncoding) throws -> String { + let content = switch try sut.readEntireFile(atURL: fileURL, withEncoding: encoding) { + case .byteBuffer(let fileData): fileData.base64EncodedString() + case .string(_, let fileData): fileData + } + return try treat(content: content, withEncoding: encoding) + } + + func fetchChunkedContent(forFile file: (name: String, extension: String), withEncoding encoding: IONFILEEncoding, forceURLError: Bool = false) throws -> String { + let fileURL = try XCTUnwrap(Bundle(for: type(of: self)).url(forResource: file.name, withExtension: file.extension)) + return try fetchChunkedContent(forURL: fileURL, withEncoding: encoding, forceURLError: forceURLError) + } + + @discardableResult + func fetchChunkedContent(forURL url: URL, withEncoding encoding: IONFILEEncoding, forceURLError: Bool = false) throws -> String { + var fileURL = url + var contentArray = [String]() + var error: Error? + let expectation = XCTestExpectation(description: "Wait for chunks to be processed") + + if forceURLError { + fileURL.deleteLastPathComponent() + } + try sut.readFileInChunks(atURL: fileURL, withEncoding: encoding, andChunkSize: 3) // 3 bytes + .sink(receiveCompletion: { completion in + if case .failure(let failure) = completion { + error = failure + } + expectation.fulfill() + }, receiveValue: { value in + let chunkToAdd = switch value { + case .byteBuffer(let chunkData): chunkData.base64EncodedString() + case .string(_, let chunkData): chunkData + } + contentArray.append(chunkToAdd) + }) + .store(in: &cancellables) + + // Wait for all chunks to be processed + wait(for: [expectation], timeout: 1.0) + + if let error { throw error } + return try treat(content: contentArray.joined(), withEncoding: encoding) + } + + func treat(content fileURLContent: String, withEncoding encoding: IONFILEEncoding) throws -> String { + var fileURLUnicodeScalars: String.UnicodeScalarView + if case .byteBuffer = encoding { + let fileURLData = try XCTUnwrap(Data(base64Encoded: fileURLContent)) + let fileURLDataString = try XCTUnwrap(String(data: fileURLData, encoding: .utf8)) + fileURLUnicodeScalars = fileURLDataString.unicodeScalars + } else { + fileURLUnicodeScalars = fileURLContent.unicodeScalars + } + + fileURLUnicodeScalars.removeAll(where: CharacterSet.newlines.contains) + return String(fileURLUnicodeScalars) + } + + func fetchConfigurationFile() -> URL? { + Bundle(for: type(of: self)).url(forResource: Configuration.fileName, withExtension: Configuration.fileExtension) + } + + func applyHourDifference(_ hour: Int, toTimestamp timestamp: Double) -> Double { + timestamp - Double(hour) * 60.0 * 60.0 * 1000.0 + } +} diff --git a/IONFilesystemLibTests/MockFileManager.swift b/IONFilesystemLibTests/MockFileManager.swift new file mode 100644 index 0000000..21a9df7 --- /dev/null +++ b/IONFilesystemLibTests/MockFileManager.swift @@ -0,0 +1,120 @@ +import Foundation + +class MockFileManager: FileManager { + private let error: MockFileManagerError? + private let shouldDirectoryHaveContent: Bool + private let urlsWithinDirectory: [URL] + private let fileAttributes: [FileAttributeKey: Any] + private let shouldBeDirectory: ObjCBool + private let mockTemporaryDirectory: URL? + + var fileExists: Bool + + private(set) var capturedPath: URL? + private(set) var capturedOriginPath: URL? + private(set) var capturedDestinationPath: URL? + private(set) var capturedIntermediateDirectories: Bool = false + private(set) var capturedSearchPathDirectory: FileManager.SearchPathDirectory? + + init(error: MockFileManagerError? = nil, shouldDirectoryHaveContent: Bool = false, urlsWithinDirectory: [URL] = [], fileExists: Bool = true, fileAttributes: [FileAttributeKey: Any] = [:], shouldBeDirectory: ObjCBool = true, mockTemporaryDirectory: URL? = nil) { + self.error = error + self.shouldDirectoryHaveContent = shouldDirectoryHaveContent + self.urlsWithinDirectory = urlsWithinDirectory + self.fileExists = fileExists + self.fileAttributes = fileAttributes + self.shouldBeDirectory = shouldBeDirectory + self.mockTemporaryDirectory = mockTemporaryDirectory + } +} + +enum MockFileManagerError: Error { + case createDirectoryError + case readDirectoryError + case deleteDirectoryError + case deleteFileError + case itemAttributesError + case moveFileError + case copyFileError +} + +extension MockFileManager { + override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey: Any]? = nil) throws { + capturedPath = url + capturedIntermediateDirectories = createIntermediates + + if let error, error == .createDirectoryError { + throw error + } + } + + override func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions = []) throws -> [URL] { + capturedPath = url + + var urls = [URL]() + if shouldDirectoryHaveContent { + urls += [url] + } + + if let error, error == .readDirectoryError { + throw error + } + + return urls + } + + override func removeItem(at url: URL) throws { + capturedPath = url + + if let error, [MockFileManagerError.deleteDirectoryError, .deleteFileError].contains(error) { + throw error + } + } + + override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + capturedSearchPathDirectory = directory + + return urlsWithinDirectory + } + + override func fileExists(atPath path: String) -> Bool { + fileExists + } + + override func fileExists(atPath path: String, isDirectory: UnsafeMutablePointer?) -> Bool { + isDirectory?.pointee = shouldBeDirectory + + return fileExists + } + + override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { + capturedPath = URL(filePath: path) + + if let error, error == .itemAttributesError { + throw error + } + + return fileAttributes + } + + override func moveItem(at srcURL: URL, to dstURL: URL) throws { + capturedOriginPath = srcURL + capturedDestinationPath = dstURL + + if let error, error == .moveFileError { + throw error + } + } + + override func copyItem(at srcURL: URL, to dstURL: URL) throws { + capturedOriginPath = srcURL + capturedDestinationPath = dstURL + + if let error, error == .copyFileError { + throw error + } + } + + override var temporaryDirectory: URL { + mockTemporaryDirectory ?? .init(filePath: "") + } +} diff --git a/IONFilesystemLibTests/file.txt b/IONFilesystemLibTests/file.txt new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/IONFilesystemLibTests/file.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/OSFilesystemLib/OSFilesystemLib.swift b/OSFilesystemLib/OSFilesystemLib.swift deleted file mode 100644 index de9a61e..0000000 --- a/OSFilesystemLib/OSFilesystemLib.swift +++ /dev/null @@ -1,12 +0,0 @@ - -public struct OSFilesystemLib { - /// Constructor method. - public init() { - // Empty constructor - // This is required for the library's callers. - } - - public func ping(_ input: String) -> String { - return "PONG_" + input - } -} diff --git a/OSFilesystemLibTests/OSFilesystemLib.swift b/OSFilesystemLibTests/OSFilesystemLib.swift deleted file mode 100644 index d63b7d5..0000000 --- a/OSFilesystemLibTests/OSFilesystemLib.swift +++ /dev/null @@ -1,11 +0,0 @@ -import OSFilesystemLib -import SafariServices -import XCTest - -final class OSFilesystemLib: XCTestCase { - -} - -extension OSFilesystemLib { - func makeSUT() -> OSFilesystemLib { .init() } -} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5d5f783 --- /dev/null +++ b/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "IONFilesystemLib", + platforms: [.iOS(.v14)], + products: [ + .library( + name: "IONFilesystemLib", + targets: ["IONFilesystemLib"] + ) + ], + targets: [ + .target( + name: "IONFilesystemLib", + path: "IONFilesystemLib" + ), + .testTarget( + name: "IONFilesystemLibTests", + dependencies: ["IONFilesystemLib"], + path: "IONFilesystemLibTests" + ) + ] +) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1a24ea7..56f42d4 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,14 +18,14 @@ default_platform(:ios) platform :ios do desc "Lane to run the unit tests" lane :unit_tests do - run_tests(scheme: "OSFilesystemLib") + run_tests(scheme: "IONFilesystemLib") end desc "Code coverage" lane :coverage do slather( - scheme: "OSFilesystemLib", - proj: "OSFilesystemLib.xcodeproj", + scheme: "IONFilesystemLib", + proj: "IONFilesystemLib.xcodeproj", output_directory: "sonar-reports", sonarqube_xml: "true" ) @@ -33,7 +33,7 @@ platform :ios do lane :lint do swiftlint( - output_file: "sonar-reports/OSFilesystemLib-swiftlint.txt", + output_file: "sonar-reports/IONFilesystemLib-swiftlint.txt", ignore_exit_status: true ) end diff --git a/scripts/build_framework.sh b/scripts/build_framework.sh index c072d75..2114de8 100755 --- a/scripts/build_framework.sh +++ b/scripts/build_framework.sh @@ -1,6 +1,6 @@ BUILD_FOLDER="build" -BUILD_SCHEME="OSFilesystemLib" -FRAMEWORK_NAME="OSFilesystemLib" +BUILD_SCHEME="IONFilesystemLib" +FRAMEWORK_NAME="IONFilesystemLib" SIMULATOR_ARCHIVE_PATH="${BUILD_FOLDER}/iphonesimulator.xcarchive" IOS_DEVICE_ARCHIVE_PATH="${BUILD_FOLDER}/iphoneos.xcarchive" diff --git a/scripts/bump_versions.rb b/scripts/bump_versions.rb index 80821e8..de5216a 100644 --- a/scripts/bump_versions.rb +++ b/scripts/bump_versions.rb @@ -6,7 +6,7 @@ level = ARGV[0] # Define the path to your .podspec file -podspec_path = "./OSFilesystemLib.podspec" +podspec_path = "./IONFilesystemLib.podspec" # Read the .podspec file podspec_content = File.read(podspec_path) @@ -48,7 +48,7 @@ File.write(podspec_path, new_podspec_content) # Set the application name -LIBRARY_NAME = "OSFilesystemLib" +LIBRARY_NAME = "IONFilesystemLib" # Set the Xcode project file path project_file = "#{LIBRARY_NAME}.xcodeproj/project.pbxproj" @@ -69,4 +69,10 @@ # Write the updated content back to the project file File.open(project_file, "w") { |file| file.puts updated_content } +readme_path = "./README.md" +readme_content = File.read(readme_path) +new_readme_content = readme_content.gsub(/(pod 'IONFilesystemLib', '~> )\d+\.\d+\.\d+/, "\\1#{new_version_number}\\2") + .gsub(/(# Use the latest )\d+\.\d+/, "\\1#{[major, minor].join('.')}\\2") +File.write(readme_path, new_readme_content) + puts "Version updated to #{new_version_number} (Build Number ##{new_build_number})" \ No newline at end of file