diff --git a/.github/workflows/MistKit.yml b/.github/workflows/MistKit.yml index 7eb3037d..86c1eb20 100644 --- a/.github/workflows/MistKit.yml +++ b/.github/workflows/MistKit.yml @@ -17,12 +17,12 @@ jobs: swift: - version: "6.1" - version: "6.2" - - version: "6.2" + - version: "6.3" nightly: true steps: - uses: actions/checkout@v4 - - uses: brightdigit/swift-build@v1.3.3 + - uses: brightdigit/swift-build@v1.4.1 - uses: sersoft-gmbh/swift-coverage-action@v4 id: coverage-files with: @@ -49,7 +49,7 @@ jobs: build: 6.2-RELEASE steps: - uses: actions/checkout@v4 - - uses: brightdigit/swift-build@v1.3.3 + - uses: brightdigit/swift-build@v1.4.1 with: windows-swift-version: ${{ matrix.swift.version }} windows-swift-build: ${{ matrix.swift.build }} @@ -62,7 +62,40 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} os: windows swift_project: MistKit - # files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + # files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-android: + name: Build on Android + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + swift: + - version: "6.1" + - version: "6.2" + android-api-level: [28, 33, 34] + steps: + - uses: actions/checkout@v4 + - name: Free disk space + if: matrix.build-only == false + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: brightdigit/swift-build@v1.4.1 + with: + scheme: ${{ env.PACKAGE_NAME }} + type: android + android-swift-version: ${{ matrix.swift.version }} + android-api-level: ${{ matrix.android-api-level }} + android-run-tests: true + # Note: Code coverage is not supported on Android builds + # The Swift Android SDK does not include LLVM coverage tools (llvm-profdata, llvm-cov) build-macos: name: Build on macOS env: @@ -74,8 +107,8 @@ jobs: matrix: include: # SPM Build Matrix - - runs-on: macos-15 - xcode: "/Applications/Xcode_26.0.app" + - runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" - runs-on: macos-15 xcode: "/Applications/Xcode_16.4.app" - runs-on: macos-15 @@ -83,23 +116,17 @@ jobs: # macOS Build Matrix - type: macos - runs-on: macos-15 - xcode: "/Applications/Xcode_16.4.app" + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" # iOS Build Matrix - type: ios - runs-on: macos-15 - xcode: "/Applications/Xcode_26.0.app" + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" deviceName: "iPhone 17 Pro" - osVersion: "26.0.1" + osVersion: "26.2" download-platform: true - - type: ios - runs-on: macos-15 - xcode: "/Applications/Xcode_16.4.app" - deviceName: "iPhone 16e" - osVersion: "18.5" - - type: ios runs-on: macos-15 xcode: "/Applications/Xcode_16.3.app" @@ -109,30 +136,30 @@ jobs: # watchOS Build Matrix - type: watchos - runs-on: macos-15 - xcode: "/Applications/Xcode_26.0.app" + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.0" + osVersion: "26.2" # tvOS Build Matrix - type: tvos - runs-on: macos-15 - xcode: "/Applications/Xcode_26.0.app" + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" deviceName: "Apple TV" - osVersion: "26.0" + osVersion: "26.2" # visionOS Build Matrix - type: visionos - runs-on: macos-15 - xcode: "/Applications/Xcode_26.0.app" + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" deviceName: "Apple Vision Pro" - osVersion: "26.0" + osVersion: "26.2" steps: - uses: actions/checkout@v4 - name: Build and Test - uses: brightdigit/swift-build@v1.3.3 + uses: brightdigit/swift-build@v1.4.1 with: scheme: ${{ env.PACKAGE_NAME }} type: ${{ matrix.type }} @@ -153,9 +180,9 @@ jobs: lint: name: Linting - if: "!contains(github.event.head_commit.message, 'ci skip')" + if: "!contains(github.event.head_commit.message, 'ci skip')" runs-on: ubuntu-latest - needs: [build-ubuntu, build-macos, build-windows] + needs: [build-ubuntu, build-macos, build-windows, build-android] env: MINT_PATH: .mint/lib MINT_LINK_PATH: .mint/bin diff --git a/.github/workflows/check-unsafe-flags.yml b/.github/workflows/check-unsafe-flags.yml new file mode 100644 index 00000000..5c308aac --- /dev/null +++ b/.github/workflows/check-unsafe-flags.yml @@ -0,0 +1,34 @@ +name: Check for unsafeFlags + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + dump-package-check: + name: Dump Swift package (authoritative) and scan JSON + runs-on: ubuntu-latest + container: + image: swift:latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Dump package JSON and check for unsafeFlags + run: | + set -euo pipefail + # Compute unsafeFlags array directly from the dump (don't store the full dump variable) + unsafe_flags=$(swift package dump-package | jq -c '[.. | objects | .unsafeFlags? // empty]') + # Check array length to decide failure + if [ "$(echo "$unsafe_flags" | jq 'length')" -gt 0 ]; then + echo "ERROR: unsafeFlags found in resolved package JSON:" + echo "$unsafe_flags" | jq '.' || true + echo "--- resolved package dump (first 200 lines) ---" + # Print a sample of the authoritative dump (re-run dump-package for the sample) + swift package dump-package | sed -n '1,200p' || true + exit 1 + else + echo "No unsafeFlags in resolved package JSON." + fi diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml new file mode 100644 index 00000000..cdd57d62 --- /dev/null +++ b/.github/workflows/swift-source-compat.yml @@ -0,0 +1,31 @@ +name: Swift Source Compatibility + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + swift-source-compat-suite: + name: Test Swift ${{ matrix.container }} For Source Compatibility Suite + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + continue-on-error: ${{ contains(matrix.container, 'nightly') }} + + strategy: + fail-fast: false + matrix: + container: + - swift:6.1 + - swift:6.2 + - swiftlang/swift:nightly-6.3-noble + + container: ${{ matrix.container }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Test Swift 6.x For Source Compatibility + run: swift build --disable-sandbox --verbose --configuration release diff --git a/Examples/Bushel/.gitignore b/Examples/Bushel/.gitignore deleted file mode 100644 index 375e0152..00000000 --- a/Examples/Bushel/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -# CloudKit Server-to-Server Private Keys -*.pem - -# Environment variables -.env - -# Build artifacts -.build/ -.swiftpm/ - -# Xcode -xcuserdata/ -*.xcworkspace - -# macOS -.DS_Store diff --git a/Examples/Bushel/CLOUDKIT-SETUP.md b/Examples/Bushel/CLOUDKIT-SETUP.md deleted file mode 100644 index d3bd3ec6..00000000 --- a/Examples/Bushel/CLOUDKIT-SETUP.md +++ /dev/null @@ -1,855 +0,0 @@ -# CloudKit Server-to-Server Authentication Setup Guide - -This guide documents the complete process for setting up CloudKit Server-to-Server (S2S) authentication to sync data from external sources to CloudKit's public database. This was implemented for the Bushel demo application, which syncs Apple restore images, Xcode versions, and Swift versions to CloudKit. - -## Table of Contents - -1. [Overview](#overview) -2. [Prerequisites](#prerequisites) -3. [Server-to-Server Key Setup](#server-to-server-key-setup) -4. [Schema Configuration](#schema-configuration) -5. [Understanding CloudKit Permissions](#understanding-cloudkit-permissions) -6. [Common Issues and Solutions](#common-issues-and-solutions) -7. [Implementation Details](#implementation-details) -8. [Testing and Verification](#testing-and-verification) - ---- - -## Overview - -### What is Server-to-Server Authentication? - -Server-to-Server (S2S) authentication allows your backend services, scripts, or command-line tools to interact with CloudKit **without requiring a signed-in iCloud user**. This is essential for: - -- Automated data syncing from external APIs -- Scheduled batch operations -- Server-side data processing -- Command-line tools that manage CloudKit data - -### How It Works - -1. **Generate a Server-to-Server key** in CloudKit Dashboard -2. **Download the private key** (.pem file) and securely store it -3. **Sign requests** using the private key and key ID -4. **CloudKit authenticates** your requests as the developer/creator -5. **Permissions are checked** against the schema's security roles - -### Key Characteristics - -- Operates at the **developer/application level**, not user level -- Authenticates as the **"_creator"** role in CloudKit's permission model -- Requires explicit permissions in your CloudKit schema -- Works with the **public database** only (not private or shared databases) - ---- - -## Prerequisites - -### 1. Apple Developer Account - -- Active Apple Developer Program membership -- Access to [CloudKit Dashboard](https://icloud.developer.apple.com/) - -### 2. CloudKit Container - -- A configured CloudKit container (e.g., `iCloud.com.yourcompany.YourApp`) -- Container must be set up in your Apple Developer account - -### 3. Tools - -- **Xcode Command Line Tools** (for `cktool`) -- **Swift** (for building and running your sync tool) -- **OpenSSL** (for generating the key pair) - -### 4. Development Environment - -```bash -# Verify you have the required tools -xcode-select --version -swift --version -openssl version -``` - ---- - -## Server-to-Server Key Setup - -### Step 1: Generate the Key Pair - -Open Terminal and generate an ECPRIME256V1 key pair: - -```bash -# Generate private key -openssl ecparam -name prime256v1 -genkey -noout -out eckey.pem - -# Extract public key -openssl ec -in eckey.pem -pubout -out eckey_pub.pem -``` - -**Important:** Keep `eckey.pem` (private key) **secure and confidential**. Never commit it to version control. - -### Step 2: Add Key to CloudKit Dashboard - -1. **Navigate to CloudKit Dashboard** - - Go to [CloudKit Dashboard](https://icloud.developer.apple.com/) - - Select your Team - - Select your Container - -2. **Navigate to Tokens & Keys** - - In the left sidebar, under "Settings" - - Click "Tokens & Keys" - -3. **Create New Server-to-Server Key** - - Click the "+" button to create a new key - - **Name:** Give it a descriptive name (e.g., "MistKit Demo for Restore Images") - - **Public Key:** Paste the contents of `eckey_pub.pem` - -4. **Save and Record Key ID** - - After saving, CloudKit will display a **Key ID** (long hexadecimal string) - - **Copy this Key ID** - you'll need it for authentication - - Example: `3e76ace055d2e3881a4e9c862dd1119ea85717bd743c1c8c15d95b2280cd93ab` - -### Step 3: Secure Storage - -Store your private key securely: - -```bash -# Option 1: iCloud Drive (encrypted) -mv eckey.pem ~/Library/Mobile\ Documents/com~apple~CloudDocs/Keys/your-app-cloudkit.pem - -# Option 2: Environment variable (for CI/CD) -export CLOUDKIT_PRIVATE_KEY=$(cat eckey.pem) - -# Option 3: Secure keychain (macOS) -# Store in macOS Keychain as a secure note -``` - -**Never:** -- Commit the private key to Git -- Share it in Slack/email -- Store it in plain text in your repository - ---- - -## Schema Configuration - -### Understanding the Schema File - -CloudKit schemas define your data structure and **security permissions**. For S2S authentication to work, you must explicitly grant permissions in your schema. - -### Schema File Format - -Create a `schema.ckdb` file: - -```text -DEFINE SCHEMA - -RECORD TYPE YourRecordType ( - "field1" STRING QUERYABLE SORTABLE SEARCHABLE, - "field2" TIMESTAMP QUERYABLE SORTABLE, - "field3" INT64 QUERYABLE, - - GRANT READ, CREATE, WRITE TO "_creator", - GRANT READ, CREATE, WRITE TO "_icloud", - GRANT READ TO "_world" -); -``` - -### Critical Permissions for S2S - -**For Server-to-Server authentication to work, you MUST include:** - -```text -GRANT READ, CREATE, WRITE TO "_creator", -GRANT READ, CREATE, WRITE TO "_icloud", -``` - -**Why both roles are required:** -- `_creator` - S2S keys authenticate as the developer/creator -- `_icloud` - Provides additional context for authenticated operations - -**Our testing showed:** -- ❌ Only `_icloud` → `ACCESS_DENIED` errors -- ❌ Only `_creator` → `ACCESS_DENIED` errors -- ✅ **Both `_creator` AND `_icloud`** → Success - -### Example: Bushel Schema - -```text -DEFINE SCHEMA - -RECORD TYPE RestoreImage ( - "version" STRING QUERYABLE SORTABLE SEARCHABLE, - "buildNumber" STRING QUERYABLE SORTABLE, - "releaseDate" TIMESTAMP QUERYABLE SORTABLE, - "downloadURL" STRING, - "fileSize" INT64, - "sha256Hash" STRING, - "sha1Hash" STRING, - "isSigned" INT64 QUERYABLE, - "isPrerelease" INT64 QUERYABLE, - "source" STRING, - "notes" STRING, - - GRANT READ, CREATE, WRITE TO "_creator", - GRANT READ, CREATE, WRITE TO "_icloud", - GRANT READ TO "_world" -); - -RECORD TYPE XcodeVersion ( - "version" STRING QUERYABLE SORTABLE SEARCHABLE, - "buildNumber" STRING QUERYABLE SORTABLE, - "releaseDate" TIMESTAMP QUERYABLE SORTABLE, - "isPrerelease" INT64 QUERYABLE, - "downloadURL" STRING, - "fileSize" INT64, - "minimumMacOS" REFERENCE, - "includedSwiftVersion" REFERENCE, - "sdkVersions" STRING, - "notes" STRING, - - GRANT READ, CREATE, WRITE TO "_creator", - GRANT READ, CREATE, WRITE TO "_icloud", - GRANT READ TO "_world" -); - -RECORD TYPE SwiftVersion ( - "version" STRING QUERYABLE SORTABLE SEARCHABLE, - "releaseDate" TIMESTAMP QUERYABLE SORTABLE, - "isPrerelease" INT64 QUERYABLE, - "downloadURL" STRING, - "notes" STRING, - - GRANT READ, CREATE, WRITE TO "_creator", - GRANT READ, CREATE, WRITE TO "_icloud", - GRANT READ TO "_world" -); -``` - -### Importing the Schema - -Use `cktool` to import your schema to CloudKit: - -```bash -xcrun cktool import-schema \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.YourApp \ - --environment development \ - --file schema.ckdb -``` - -**Note:** You'll be prompted to authenticate with your Apple ID. This requires a management token, which `cktool` will help you obtain. - -### Verifying the Schema - -Export and verify your schema was imported correctly: - -```bash -xcrun cktool export-schema \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.YourApp \ - --environment development \ - > current-schema.ckdb - -# Check the permissions -cat current-schema.ckdb | grep -A 2 "GRANT" -``` - -You should see: -```text -GRANT READ, CREATE, WRITE TO "_creator", -GRANT READ, CREATE, WRITE TO "_icloud", -GRANT READ TO "_world" -``` - ---- - -## Understanding CloudKit Permissions - -### Security Roles - -CloudKit uses a role-based permission system with three built-in roles: - -| Role | Who | Typical Use | -|------|-----|-------------| -| `_world` | Everyone (including unauthenticated users) | Public read access | -| `_icloud` | Any signed-in iCloud user | User-level operations | -| `_creator` | The developer/owner of the container | Admin/server operations | - -### Permission Types - -| Permission | What It Allows | -|------------|----------------| -| `READ` | Query and fetch records | -| `CREATE` | Create new records | -| `WRITE` | Update existing records | - -### How S2S Authentication Maps to Roles - -When you use Server-to-Server authentication: - -1. Your private key + key ID authenticate you **as the developer** -2. CloudKit treats this as the **`_creator`** role -3. For public database operations, **both `_creator` and `_icloud`** permissions are needed - -### Common Permission Patterns - -**Public read-only data:** -```text -GRANT READ TO "_world" -``` - -**User-generated content:** -```text -GRANT READ, CREATE, WRITE TO "_creator", -GRANT READ, WRITE TO "_icloud", -GRANT READ TO "_world" -``` - -**Server-managed data (our use case):** -```text -GRANT READ, CREATE, WRITE TO "_creator", -GRANT READ, CREATE, WRITE TO "_icloud", -GRANT READ TO "_world" -``` - -**Admin-only data:** -```text -GRANT READ, CREATE, WRITE TO "_creator" -``` - -### CloudKit Dashboard UI vs Schema Syntax - -The CloudKit Dashboard shows permissions with checkboxes: -- ☑️ Create -- ☑️ Read -- ☑️ Write - -In the schema file, these map to: -```text -GRANT READ, CREATE, WRITE TO "role_name" -``` - -**Important:** The Dashboard and schema file should match. Always verify by exporting the schema after making Dashboard changes. - ---- - -## Common Issues and Solutions - -### Issue 1: ACCESS_DENIED - "CREATE operation not permitted" - -**Symptom:** -```json -{ - "recordName": "YourRecord-123", - "reason": "CREATE operation not permitted", - "serverErrorCode": "ACCESS_DENIED" -} -``` - -**Root Causes:** - -1. **Missing `_creator` permissions in schema** - - **Solution:** Update schema to include: - ```text - GRANT READ, CREATE, WRITE TO "_creator", - ``` - -2. **Missing `_icloud` permissions in schema** - - **Solution:** Update schema to include: - ```text - GRANT READ, CREATE, WRITE TO "_icloud", - ``` - -3. **Schema not properly imported to CloudKit** - - **Solution:** Re-import schema using `cktool import-schema` - -4. **Server-to-Server key not active** - - **Solution:** Check CloudKit Dashboard → Tokens & Keys → Verify key is active - -### Issue 2: AUTHENTICATION_FAILED (HTTP 401) - -**Symptom:** -```text -HTTP 401: Authentication failed -``` - -**Root Causes:** - -1. **Invalid or revoked Key ID** - - **Solution:** Generate a new S2S key in CloudKit Dashboard - -2. **Incorrect private key** - - **Solution:** Verify you're using the correct `.pem` file - -3. **Key ID and private key mismatch** - - **Solution:** Ensure the private key matches the public key registered for that Key ID - -### Issue 3: Schema Syntax Errors - -**Symptom:** -```text -Was expecting LIST -Encountered "QUERYABLE" at line X, column Y -``` - -**Root Causes:** - -1. **System fields cannot have modifiers** - - **Bad:** - ```text - ___recordName QUERYABLE - ``` - - **Good:** Omit system fields entirely (CloudKit adds them automatically) - -2. **Invalid field type** - - **Solution:** Use CloudKit's supported types: - - `STRING` - - `INT64` (not `BOOLEAN` - use `INT64` with 0/1) - - `DOUBLE` - - `TIMESTAMP` - - `REFERENCE` - - `ASSET` - - `LOCATION` - - `LIST` - -### Issue 4: JSON Parsing Error (HTTP 500) - -**Symptom:** -```text -HTTP 500: The data couldn't be read because it isn't in the correct format -``` - -**Root Cause:** -Response payload is too large (>500KB). This is a **client-side** parsing limitation, **not a CloudKit error**. - -**Evidence it still worked:** -- HTTP 200 response received -- Record data present in response body -- Records exist in CloudKit when queried - -**Solutions:** - -1. **Reduce batch size** (CloudKit allows up to 200 operations per request) - ```swift - let batchSize = 100 // Instead of 200 - ``` - -2. **Don't decode the entire response** - just check for errors - ```swift - // Parse just the serverErrorCode field - let json = try JSONSerialization.jsonObject(with: data) - ``` - -3. **Use streaming JSON parser** for large responses - -4. **Verify success by querying CloudKit** after sync - -### Issue 5: Boolean Fields in CloudKit - -**Symptom:** -CloudKit schema import fails or fields have wrong type - -**Root Cause:** -CloudKit doesn't have a native `BOOLEAN` type in the schema language. - -**Solution:** -Use `INT64` with `0` for false and `1` for true: - -**Schema:** -```text -isPrerelease INT64 QUERYABLE, -isSigned INT64 QUERYABLE, -``` - -**Swift code:** -```swift -fields["isSigned"] = .int64(record.isSigned ? 1 : 0) -fields["isPrerelease"] = .int64(record.isPrerelease ? 1 : 0) -``` - ---- - -## Implementation Details - -### Swift Package Structure - -```text -Sources/ -├── YourApp/ -│ ├── CloudKit/ -│ │ ├── YourAppCloudKitService.swift # Main service wrapper -│ │ ├── RecordBuilder.swift # Converts models to CloudKit operations -│ │ └── Models.swift # Data models -│ └── DataSources/ -│ ├── ExternalAPIFetcher.swift # Fetch from external sources -│ └── ... -``` - -### Initialize CloudKit Service - -```swift -import MistKit - -// Initialize with S2S authentication -let service = try BushelCloudKitService( - containerIdentifier: "iCloud.com.yourcompany.YourApp", - keyID: "3e76ace055d2e3881a4e9c862dd1119ea85717bd743c1c8c15d95b2280cd93ab", - privateKeyPath: "/path/to/your-cloudkit.pem" -) -``` - -**Under the hood** (MistKit implementation): - -```swift -struct BushelCloudKitService { - let service: CloudKitService - - init(containerIdentifier: String, keyID: String, privateKeyPath: String) throws { - // Read PEM file - guard FileManager.default.fileExists(atPath: privateKeyPath) else { - throw BushelCloudKitError.privateKeyFileNotFound(path: privateKeyPath) - } - - let pemString = try String(contentsOfFile: privateKeyPath, encoding: .utf8) - - // Create S2S authentication manager - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: pemString - ) - - // Initialize CloudKit service - self.service = try CloudKitService( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: .development, - database: .public - ) - } -} -``` - -### Building CloudKit Operations - -Use `.forceReplace` for idempotent operations: - -```swift -static func buildRestoreImageOperation(_ record: RestoreImageRecord) -> RecordOperation { - var fields: [String: FieldValue] = [:] - - fields["version"] = .string(record.version) - fields["buildNumber"] = .string(record.buildNumber) - fields["releaseDate"] = .timestamp(record.releaseDate) - fields["downloadURL"] = .string(record.downloadURL) - fields["fileSize"] = .int64(Int64(record.fileSize)) - fields["sha256Hash"] = .string(record.sha256Hash) - fields["sha1Hash"] = .string(record.sha1Hash) - fields["isSigned"] = .int64(record.isSigned ? 1 : 0) - fields["isPrerelease"] = .int64(record.isPrerelease ? 1 : 0) - fields["source"] = .string(record.source) - if let notes = record.notes { - fields["notes"] = .string(notes) - } - - return RecordOperation( - operationType: .forceReplace, // Create if not exists, update if exists - recordType: "RestoreImage", - recordName: record.recordName, - fields: fields - ) -} -``` - -**Why `.forceReplace`?** -- Idempotent: Running sync multiple times won't create duplicates -- Creates new records if they don't exist -- Updates existing records with new data -- Requires both `CREATE` and `WRITE` permissions - -### Batch Operations - -CloudKit limits operations to **200 per request**: - -```swift -func syncRecords(_ records: [RestoreImageRecord]) async throws { - let operations = records.map { record in - RecordOperation.create( - recordType: RestoreImageRecord.cloudKitRecordType, - recordName: record.recordName, - fields: record.toCloudKitFields() - ) - } - - let batchSize = 200 - let batches = operations.chunked(into: batchSize) - - for (index, batch) in batches.enumerated() { - print("Batch \(index + 1)/\(batches.count): \(batch.count) records...") - let results = try await service.modifyRecords(batch) - - // Check for errors - let failures = results.filter { $0.recordType == "Unknown" } - let successes = results.filter { $0.recordType != "Unknown" } - - print("✓ \(successes.count) succeeded, ❌ \(failures.count) failed") - } -} -``` - -### Error Handling - -CloudKit returns **partial success** - some operations may succeed while others fail: - -```swift -let results = try await service.modifyRecords(batch) - -// CloudKit returns mixed results -for result in results { - if result.recordType == "Unknown" { - // This is an error response - print("❌ Error: \(result.serverErrorCode)") - print(" Reason: \(result.reason)") - } else { - // Successfully created/updated - print("✓ Record: \(result.recordName)") - } -} -``` - -Common error codes: -- `ACCESS_DENIED` - Permissions issue -- `AUTHENTICATION_FAILED` - Invalid key ID or signature -- `CONFLICT` - Record version mismatch (use `.forceReplace` to avoid) -- `QUOTA_EXCEEDED` - Too many operations or storage limit reached - ---- - -## Testing and Verification - -### 1. Test Authentication - -```swift -// Try a simple query to verify auth works -let records = try await service.queryRecords(recordType: "YourRecordType", limit: 1) -print("✓ Authentication successful, found \(records.count) records") -``` - -### 2. Verify Schema Permissions - -```bash -# Export current schema -xcrun cktool export-schema \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.YourApp \ - --environment development - -# Check permissions include _creator and _icloud -# Look for: -# GRANT READ, CREATE, WRITE TO "_creator", -# GRANT READ, CREATE, WRITE TO "_icloud", -``` - -### 3. Test Record Creation - -```swift -// Create a test record -let testRecord = RestoreImageRecord( - version: "18.0", - buildNumber: "22A123", - releaseDate: Date(), - downloadURL: "https://example.com/test.ipsw", - fileSize: 1000000, - sha256Hash: "abc123", - sha1Hash: "def456", - isSigned: true, - isPrerelease: false, - source: "test" -) - -let operation = RecordOperation.create( - recordType: RestoreImageRecord.cloudKitRecordType, - recordName: testRecord.recordName, - fields: testRecord.toCloudKitFields() -) -let results = try await service.modifyRecords([operation]) - -if results.first?.recordType == "Unknown" { - print("❌ Failed: \(results.first?.reason ?? "unknown")") -} else { - print("✓ Success! Record created: \(results.first?.recordName ?? "")") -} -``` - -### 4. Query Records from CloudKit - -```bash -# Using cktool (requires management token) -xcrun cktool query \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.YourApp \ - --environment development \ - --record-type RestoreImage \ - --limit 10 -``` - -Or in Swift: - -```swift -let records = try await service.queryRecords( - recordType: "RestoreImage", - limit: 10 -) - -for record in records { - print("Record: \(record.recordName)") - print(" Version: \(record.fields["version"]?.stringValue ?? "N/A")") - print(" Build: \(record.fields["buildNumber"]?.stringValue ?? "N/A")") -} -``` - -### 5. Verify in CloudKit Dashboard - -1. Go to [CloudKit Dashboard](https://icloud.developer.apple.com/) -2. Select your Container -3. Navigate to **Data** in the left sidebar -4. Select **Public Database** -5. Choose your **Record Type** (e.g., "RestoreImage") -6. You should see your synced records - ---- - -## Complete Setup Checklist - -### Initial Setup - -- [ ] Generate ECPRIME256V1 key pair with OpenSSL -- [ ] Add public key to CloudKit Dashboard → Tokens & Keys -- [ ] Copy and securely store the Key ID -- [ ] Store private key in secure location (not in Git!) -- [ ] Create `schema.ckdb` with proper permissions -- [ ] Import schema using `cktool import-schema` -- [ ] Verify schema with `cktool export-schema` - -### Schema Requirements - -- [ ] All record types have `GRANT READ, CREATE, WRITE TO "_creator"` -- [ ] All record types have `GRANT READ, CREATE, WRITE TO "_icloud"` -- [ ] Public read access: `GRANT READ TO "_world"` (if needed) -- [ ] No system fields (___*) have QUERYABLE modifiers -- [ ] Boolean fields use INT64 type (0/1) -- [ ] All REFERENCE fields point to valid record types - -### Code Implementation - -- [ ] Initialize `ServerToServerAuthManager` with keyID and PEM string -- [ ] Create `CloudKitService` with public database -- [ ] Build `RecordOperation` with `.forceReplace` for idempotency -- [ ] Implement batch processing (max 200 operations per request) -- [ ] Handle partial failures in responses -- [ ] Filter error responses (`recordType == "Unknown"`) - -### Testing - -- [ ] Test authentication with simple query -- [ ] Verify record creation works -- [ ] Check records appear in CloudKit Dashboard -- [ ] Test batch operations with multiple records -- [ ] Verify idempotency (running sync twice doesn't duplicate) -- [ ] Test error handling (invalid data, quota limits) - -### Production Readiness - -- [ ] Switch to `.production` environment -- [ ] Import schema to production container -- [ ] Rotate keys regularly (create new S2S key every 6-12 months) -- [ ] Monitor CloudKit usage and quotas -- [ ] Set up logging/monitoring for sync operations -- [ ] Document key rotation procedure -- [ ] Add rate limiting to avoid quota exhaustion - ---- - -## Additional Resources - -### Apple Documentation - -- [CloudKit Web Services Reference](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/) -- [CloudKit Console Guide](https://developer.apple.com/documentation/cloudkit/managing_icloud_containers_with_the_cloudkit_database_app) -- [Server-to-Server Authentication](https://developer.apple.com/documentation/cloudkit/ckoperation) - -### MistKit Documentation - -- [MistKit GitHub Repository](https://github.com/brightdigit/MistKit) -- [Server-to-Server Auth Example](https://github.com/brightdigit/MistKit/tree/main/Examples/Bushel) - -### Tools - -- **cktool**: `xcrun cktool --help` -- **OpenSSL**: `man openssl` -- **Swift**: `swift --help` - ---- - -## Troubleshooting Commands - -```bash -# Check cktool is available -xcrun cktool --version - -# List your CloudKit containers -xcrun cktool list-containers - -# Export current schema -xcrun cktool export-schema \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.YourApp \ - --environment development - -# Import updated schema -xcrun cktool import-schema \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.YourApp \ - --environment development \ - --file schema.ckdb - -# Query records -xcrun cktool query \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.YourApp \ - --environment development \ - --record-type YourRecordType - -# Validate private key format -openssl ec -in your-cloudkit.pem -text -noout -``` - ---- - -## Summary - -CloudKit Server-to-Server authentication requires: - -1. **Key pair generation** - ECPRIME256V1 format -2. **CloudKit Dashboard setup** - Register public key, get Key ID -3. **Schema permissions** - Grant to **both** `_creator` and `_icloud` -4. **Swift implementation** - Use MistKit's `ServerToServerAuthManager` -5. **Operation type** - Use `.forceReplace` for idempotency -6. **Error handling** - Parse responses, handle partial failures -7. **Testing** - Verify auth, permissions, and record creation - -The most critical requirement discovered through testing: - -> **Both `_creator` AND `_icloud` must have `READ, CREATE, WRITE` permissions for S2S authentication to work with the public database.** - -This configuration allows your server-side tools to manage CloudKit data programmatically while also enabling public read access for your apps. diff --git a/Examples/Bushel/CLOUDKIT_SCHEMA_SETUP.md b/Examples/Bushel/CLOUDKIT_SCHEMA_SETUP.md deleted file mode 100644 index f0c0d483..00000000 --- a/Examples/Bushel/CLOUDKIT_SCHEMA_SETUP.md +++ /dev/null @@ -1,285 +0,0 @@ -# CloudKit Schema Setup Guide - -This guide explains how to set up the CloudKit schema for the Bushel demo application. - -## Two Approaches - -### Option 1: Automated Setup with cktool (Recommended) - -Use the provided script to automatically import the schema. - -#### Prerequisites - -- **Xcode 13+** installed (provides `cktool`) -- **CloudKit container** created in [CloudKit Dashboard](https://icloud.developer.apple.com/) -- **Apple Developer Team ID** (10-character identifier) -- **CloudKit Management Token** (see "Getting a Management Token" below) - -#### Steps - -1. **Save your CloudKit Management Token** - - ```bash - xcrun cktool save-token - ``` - - When prompted, paste your management token from CloudKit Dashboard. - -2. **Set environment variables** - - ```bash - export CLOUDKIT_CONTAINER_ID="iCloud.com.yourcompany.Bushel" - export CLOUDKIT_TEAM_ID="YOUR_TEAM_ID" - export CLOUDKIT_ENVIRONMENT="development" # or "production" - ``` - -3. **Run the setup script** - - ```bash - cd Examples/Bushel - ./Scripts/setup-cloudkit-schema.sh - ``` - - The script will: - - Validate the schema file - - Confirm before importing - - Import the schema to your CloudKit container - - Display success/error messages - -4. **Verify in CloudKit Dashboard** - - Open [CloudKit Dashboard](https://icloud.developer.apple.com/) and verify the three record types exist: - - RestoreImage - - XcodeVersion - - SwiftVersion - -### Option 2: Manual Schema Creation (Development Only) - -For quick development testing, you can use CloudKit's "just-in-time schema" feature. - -#### Steps - -1. **Run the CLI with export command** (no schema needed) - - ```bash - bushel-images export --output test-data.json - ``` - - This fetches data from APIs without CloudKit. - -2. **Temporarily modify SyncCommand to create test records** - - Add this to `SyncCommand.swift`: - - ```swift - // In run() method, before actual sync: - let testImage = RestoreImageRecord( - version: "15.0", - buildNumber: "24A335", - releaseDate: Date(), - downloadURL: "https://example.com/test.ipsw", - fileSize: 1000000, - sha256Hash: "test", - sha1Hash: "test", - isSigned: true, - isPrerelease: false, - source: "test" - ) - - let operation = RecordOperation.create( - recordType: RestoreImageRecord.cloudKitRecordType, - recordName: testImage.recordName, - fields: testImage.toCloudKitFields() - ) - try await service.modifyRecords([operation]) - ``` - -3. **Run sync once** - - ```bash - bushel-images sync - ``` - - CloudKit will auto-create the record types in development. - -4. **Deploy schema to production** (when ready) - - In CloudKit Dashboard: - - Go to Schema section - - Click "Deploy Schema Changes" - - Review and confirm - -⚠️ **Note**: Just-in-time schema creation only works in development environment and doesn't set up indexes. - -## Getting a Management Token - -Management tokens allow `cktool` to modify your CloudKit schema. - -1. Open [CloudKit Dashboard](https://icloud.developer.apple.com/) -2. Select your container -3. Click your profile icon (top right) -4. Select "Manage Tokens" -5. Click "Create Token" -6. Give it a name: "Bushel Schema Management" -7. **Copy the token** (you won't see it again!) -8. Save it using `xcrun cktool save-token` - -## Schema File Format - -The schema is defined in `schema.ckdb` using CloudKit's declarative schema language: - -```text -RECORD TYPE RestoreImage ( - "version" STRING QUERYABLE SORTABLE SEARCHABLE, - "buildNumber" STRING QUERYABLE SORTABLE, - "releaseDate" TIMESTAMP QUERYABLE SORTABLE, - "fileSize" INT64, - "isSigned" INT64 QUERYABLE, - // ... more fields - - GRANT WRITE TO "_creator", - GRANT READ TO "_world" -); -``` - -### Key Features - -- **QUERYABLE**: Field can be used in query predicates -- **SORTABLE**: Field can be used for sorting results -- **SEARCHABLE**: Field supports full-text search -- **GRANT READ TO "_world"**: Makes records publicly readable -- **GRANT WRITE TO "_creator"**: Only creator can modify - -### Database Scope - -**Important**: The schema import applies to the **container level**, making record types available in both public and private databases. However: - -- The **Bushel demo writes to the public database** (`BushelCloudKitService.swift:16`) -- The `GRANT READ TO "_world"` permission ensures public read access -- Other apps (like Bushel itself) query the **public database** directly - -This architecture allows: -- The demo app (MistKit) to populate data in the public database -- Bushel (native CloudKit) to read that data without authentication - -### Field Type Notes - -- **Boolean → INT64**: CloudKit doesn't have a native boolean type, so we use INT64 (0 = false, 1 = true) -- **TIMESTAMP**: CloudKit's date/time field type -- **REFERENCE**: Link to another record (for relationships) - -## Schema Export - -To export your current schema (useful for version control): - -```bash -xcrun cktool export-schema \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.Bushel \ - --environment development \ - --output-file schema-backup.ckdb -``` - -## Validation Without Import - -To validate your schema file without importing: - -```bash -xcrun cktool validate-schema \ - --team-id YOUR_TEAM_ID \ - --container-id iCloud.com.yourcompany.Bushel \ - --environment development \ - schema.ckdb -``` - -## Common Issues - -### Authentication Failed - -**Problem**: "Authentication failed" or "Invalid token" - -**Solution**: -1. Generate a new management token in CloudKit Dashboard -2. Save it: `xcrun cktool save-token` -3. Ensure you're using the correct Team ID - -### Container Not Found - -**Problem**: "Container not found" or "Invalid container" - -**Solution**: -- Verify container ID matches CloudKit Dashboard exactly -- Ensure container exists and you have access -- Check Team ID is correct - -### Schema Validation Errors - -**Problem**: "Schema validation failed" with field type errors - -**Solution**: -- Ensure all field types match CloudKit's supported types -- Remember: Use INT64 for booleans, TIMESTAMP for dates -- Check for typos in field names - -### Permission Denied - -**Problem**: "Insufficient permissions to modify schema" - -**Solution**: -- Verify your Apple ID has Admin role in the container -- Check management token has correct permissions -- Try regenerating the management token - -## CI/CD Integration - -For automated deployment, you can integrate schema management into your CI/CD pipeline: - -```bash -#!/bin/bash -# In your CI/CD script - -# Load token from secure environment variable -echo "$CLOUDKIT_MANAGEMENT_TOKEN" | xcrun cktool save-token --file - - -# Import schema -xcrun cktool import-schema \ - --team-id "$TEAM_ID" \ - --container-id "$CONTAINER_ID" \ - --environment development \ - schema.ckdb -``` - -## Schema Versioning - -Best practices for managing schema changes: - -1. **Version Control**: Keep `schema.ckdb` in git -2. **Development First**: Always test changes in development environment -3. **Schema Export**: Periodically export production schema as backup -4. **Migration Plan**: Document any breaking changes -5. **Backward Compatibility**: Avoid removing fields when possible - -## Next Steps - -After setting up the schema: - -1. **Configure credentials**: See [XCODE_SCHEME_SETUP.md](./XCODE_SCHEME_SETUP.md) -2. **Run data sync**: `bushel-images sync` -3. **Verify data**: Check CloudKit Dashboard for records -4. **Test queries**: Use CloudKit Dashboard's Data section - -## Resources - -- [CloudKit Schema Documentation](https://developer.apple.com/documentation/cloudkit/designing-and-creating-a-cloudkit-database) -- [cktool Reference](https://keith.github.io/xcode-man-pages/cktool.1.html) -- [WWDC21: Automate CloudKit tests with cktool](https://developer.apple.com/videos/play/wwdc2021/10118/) -- [CloudKit Dashboard](https://icloud.developer.apple.com/) - -## Troubleshooting - -For Bushel-specific issues, see the main [README.md](./README.md). - -For CloudKit schema issues: -- Check [Apple Developer Forums](https://developer.apple.com/forums/tags/cloudkit) -- Review CloudKit Dashboard logs -- Verify schema file syntax against Apple's documentation diff --git a/Examples/Bushel/IMPLEMENTATION_NOTES.md b/Examples/Bushel/IMPLEMENTATION_NOTES.md deleted file mode 100644 index 3992b7b9..00000000 --- a/Examples/Bushel/IMPLEMENTATION_NOTES.md +++ /dev/null @@ -1,430 +0,0 @@ -# Bushel Demo Implementation Notes - -## Session Summary: AppleDB Integration & S2S Authentication Refactoring - -This document captures key implementation decisions, issues encountered, and solutions applied during the development of the Bushel CloudKit demo. Use this as a reference when building similar demos (e.g., Celestra). - ---- - -## Major Changes Completed - -### 1. AppleDB Data Source Integration - -**Purpose**: Fetch comprehensive restore image data with device-specific signing status for VirtualMac2,1 to complement ipsw.me's data. - -**Implementation**: -- Integrated AppleDB API for device-specific restore image information -- Created modern, error-handled implementation with Swift 6 concurrency -- Integrated as an additional fetcher in `DataSourcePipeline` - -**Files Created**: -```text -AppleDB/ -├── AppleDBParser.swift # Fetches from api.appledb.dev -├── AppleDBFetcher.swift # Implements fetcher pattern -└── Models/ - ├── AppleDBVersion.swift # Domain model with CloudKit helpers - └── AppleDBAPITypes.swift # API response types -``` - -**Key Features**: -- Device filtering for VirtualMac variants -- File size parsing (string → Int64 for CloudKit) -- Prerelease detection (beta/RC in version string) -- Robust error handling with custom error types - -**Integration Point**: -```swift -// DataSourcePipeline.swift -async let appleDBImages = options.includeAppleDB - ? AppleDBFetcher().fetch() - : [RestoreImageRecord]() -``` - -### 2. Server-to-Server Authentication Refactoring - -**Motivation**: -- Server-to-Server Keys are the recommended enterprise authentication method -- More secure than API Tokens (private key never transmitted, only signatures) -- Better demonstrates production-ready CloudKit integration - -**What Changed**: - -| Before (API Token) | After (Server-to-Server Key) | -|-------------------|------------------------------| -| Single token string | Key ID + Private Key (.pem file) | -| `APITokenManager` | `ServerToServerAuthManager` | -| `CLOUDKIT_API_TOKEN` env var | `CLOUDKIT_KEY_ID` + `CLOUDKIT_KEY_FILE` | -| `--api-token` flag | `--key-id` + `--key-file` flags | - -**Files Modified**: -1. `BushelCloudKitService.swift` - Switch to `ServerToServerAuthManager` -2. `SyncEngine.swift` - Update initializer parameters -3. `SyncCommand.swift` - New CLI options and env vars -4. `ExportCommand.swift` - New CLI options and env vars -5. `setup-cloudkit-schema.sh` - Updated instructions -6. `README.md` - Comprehensive S2S documentation - -**New Usage**: -```bash -# Command-line flags -bushel-images sync \ - --key-id "YOUR_KEY_ID" \ - --key-file ./private-key.pem - -# Environment variables (recommended) -export CLOUDKIT_KEY_ID="YOUR_KEY_ID" -export CLOUDKIT_KEY_FILE="$HOME/.cloudkit/bushel-private-key.pem" -bushel-images sync -``` - ---- - -## Critical Issues Solved - -### Issue 1: CloudKit Schema File Format - -**Problem**: `cktool validate-schema` failed with parsing error. - -**Root Cause**: Schema file was missing `DEFINE SCHEMA` header and included CloudKit system fields. - -**Solution**: -```text -# Before (incorrect) -RECORD TYPE RestoreImage ( - "__recordID" RECORD ID, # ❌ System fields shouldn't be in schema - ... -) - -# After (correct) -DEFINE SCHEMA - -RECORD TYPE RestoreImage ( - "version" STRING QUERYABLE, # ✅ Only user-defined fields - ... -) -``` - -**Lesson**: CloudKit automatically adds system fields (`__recordID`, `___createTime`, etc.). Never include them in schema definitions. - -### Issue 2: Authentication Terminology Confusion - -**Problem**: Confusing "API Token", "Server-to-Server Key", "Management Token", and "User Token". - -**Clarification**: - -| Token Type | Used For | Used By | Where to Get | -|-----------|----------|---------|--------------| -| **Management Token** | Schema operations (import/export) | `cktool` | Dashboard → CloudKit Web Services | -| **Server-to-Server Key** | Runtime API operations (server-side) | `ServerToServerAuthManager` | Dashboard → Server-to-Server Keys | -| **API Token** | Runtime API operations (simpler) | `APITokenManager` | Dashboard → API Tokens | -| **User Token** | User-specific operations | Web apps with user auth | OAuth-like flow | - -**For Bushel Demo**: -- Schema setup: **Management Token** (via `cktool save-token`) -- Sync/Export commands: **Server-to-Server Key** (Key ID + .pem file) - -### Issue 3: cktool Command Syntax - -**Problem**: Script used non-existent `list-containers` command and missing `--file` flag. - -**Fixes**: -```bash -# Token check (before - wrong) -xcrun cktool list-containers # ❌ Not a valid command - -# Token check (after - correct) -xcrun cktool get-teams # ✅ Valid command that requires auth - -# Schema validation (before - wrong) -xcrun cktool validate-schema ... "$SCHEMA_FILE" # ❌ Missing --file - -# Schema validation (after - correct) -xcrun cktool validate-schema ... --file "$SCHEMA_FILE" # ✅ Correct syntax -``` - ---- - -## MistKit Authentication Architecture - -### How ServerToServerAuthManager Works - -1. **Initialization**: -```swift -let tokenManager = try ServerToServerAuthManager( - keyID: "YOUR_KEY_ID", - pemString: pemFileContents // Reads from .pem file -) -``` - -2. **What happens internally**: - - Parses PEM string into ECDSA P-256 private key - - Stores key ID and private key data - - Creates `TokenCredentials` with `.serverToServer` method - -3. **Request signing** (handled by MistKit): - - For each CloudKit API request - - Creates signature using private key - - Sends Key ID + signature in headers - - Server verifies with public key - -### BushelCloudKitService Pattern - -```swift -struct BushelCloudKitService { - init(containerIdentifier: String, keyID: String, privateKeyPath: String) throws { - // 1. Validate file exists - guard FileManager.default.fileExists(atPath: privateKeyPath) else { - throw BushelCloudKitError.privateKeyFileNotFound(path: privateKeyPath) - } - - // 2. Read PEM file - let pemString = try String(contentsOfFile: privateKeyPath, encoding: .utf8) - - // 3. Create auth manager - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: pemString - ) - - // 4. Create CloudKit service - self.service = try CloudKitService( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: .development, - database: .public - ) - } -} -``` - ---- - -## Data Source Integration Pattern - -### Adding a New Data Source (AppleDB Example) - -**Step 1: Create Fetcher** -```swift -struct AppleDBFetcher: Sendable { - func fetch() async throws -> [RestoreImageRecord] { - // Fetch and parse data - // Map to CloudKit record model - // Return array - } -} -``` - -**Step 2: Add to Pipeline Options** -```swift -struct DataSourcePipeline { - struct Options: Sendable { - var includeAppleDB: Bool = true - } -} -``` - -**Step 3: Integrate into Pipeline** -```swift -private func fetchRestoreImages(options: Options) async throws -> [RestoreImageRecord] { - // Parallel fetching - async let appleDBImages = options.includeAppleDB - ? AppleDBFetcher().fetch() - : [RestoreImageRecord]() - - // Collect results - allImages.append(contentsOf: try await appleDBImages) - - // Deduplicate by buildNumber - return deduplicateRestoreImages(allImages) -} -``` - -**Step 4: Add CLI Option** -```swift -struct SyncCommand { - @Flag(name: .long, help: "Exclude AppleDB.dev as data source") - var noAppleDB: Bool = false - - private func buildSyncOptions() -> SyncEngine.SyncOptions { - if noAppleDB { - pipelineOptions.includeAppleDB = false - } - } -} -``` - -### Deduplication Strategy - -Bushel uses **buildNumber** as the unique key: - -```swift -private func deduplicateRestoreImages(_ images: [RestoreImageRecord]) -> [RestoreImageRecord] { - var uniqueImages: [String: RestoreImageRecord] = [:] - - for image in images { - let key = image.buildNumber - - if let existing = uniqueImages[key] { - // Merge records, prefer most complete data - uniqueImages[key] = mergeRestoreImages(existing, image) - } else { - uniqueImages[key] = image - } - } - - return Array(uniqueImages.values).sorted { $0.releaseDate > $1.releaseDate } -} -``` - -**Merge Priority**: -1. ipsw.me (most complete: has both SHA1 + SHA256) -2. AppleDB (device-specific signing status, comprehensive coverage) -3. MESU (freshness detection only) -4. MrMacintosh (beta/RC releases) - ---- - -## Security Best Practices - -### Private Key Management - -**Storage**: -```bash -# Create secure directory -mkdir -p ~/.cloudkit -chmod 700 ~/.cloudkit - -# Store private key securely -mv ~/Downloads/AuthKey_*.pem ~/.cloudkit/bushel-private-key.pem -chmod 600 ~/.cloudkit/bushel-private-key.pem -``` - -**Environment Setup**: -```bash -# Add to ~/.zshrc or ~/.bashrc -export CLOUDKIT_KEY_ID="your_key_id" -export CLOUDKIT_KEY_FILE="$HOME/.cloudkit/bushel-private-key.pem" -``` - -**Git Protection**: -```gitignore -# .gitignore -*.pem -.env -``` - -**Never**: -- ❌ Commit .pem files to version control -- ❌ Share private keys in Slack/email -- ❌ Store in public locations -- ❌ Use same key across development/production - -**Always**: -- ✅ Use environment variables -- ✅ Set restrictive file permissions (600) -- ✅ Store in user-specific locations (~/.cloudkit/) -- ✅ Generate separate keys per environment -- ✅ Rotate keys periodically - ---- - -## Common Error Messages & Solutions - -### "Private key file not found" -```text -BushelCloudKitError.privateKeyFileNotFound(path: "./key.pem") -``` -**Solution**: Use absolute path or ensure working directory is correct. - -### "PEM string is invalid" -```text -TokenManagerError.invalidCredentials(.invalidPEMFormat) -``` -**Solution**: Verify .pem file is valid. Check for: -- Correct BEGIN/END markers -- No corruption during download -- Proper encoding (UTF-8) - -### "Key ID is empty" -```text -TokenManagerError.invalidCredentials(.keyIdEmpty) -``` -**Solution**: Ensure `CLOUDKIT_KEY_ID` is set or `--key-id` is provided. - -### "Schema validation failed: Was expecting DEFINE" -```text -❌ Schema validation failed: Encountered "RECORD" at line 1 -Was expecting: "DEFINE" ... -``` -**Solution**: Add `DEFINE SCHEMA` header at top of schema.ckdb file. - ---- - -## CloudKit Dashboard Navigation - -### Schema Setup (Management Token) -1. Go to [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/) -2. Select your container -3. Navigate to: **API Access** → **CloudKit Web Services** -4. Click **Generate Management Token** -5. Copy token and run: `xcrun cktool save-token` - -### Runtime Auth (Server-to-Server Key) -1. Go to [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/) -2. Select your container -3. Navigate to: **API Access** → **Server-to-Server Keys** -4. Click **Create a Server-to-Server Key** -5. Download .pem file (can't download again!) -6. Note the Key ID displayed - ---- - -## Testing Checklist - -Before considering Bushel complete: - -- [ ] Schema imports successfully with `setup-cloudkit-schema.sh` -- [ ] Sync command fetches from all data sources -- [ ] AppleDB fetcher returns VirtualMac2,1 data -- [ ] Deduplication works correctly (no duplicate buildNumbers) -- [ ] Records upload to CloudKit public database -- [ ] Export command retrieves and formats data -- [ ] Error messages are helpful -- [ ] Private keys are properly protected (.gitignore) -- [ ] Documentation is complete and accurate - ---- - -## Lessons for Celestra Demo - -When building the Celestra demo, apply these patterns: - -1. **Authentication**: Start with Server-to-Server Keys from the beginning -2. **Schema**: Always include `DEFINE SCHEMA` header, no system fields -3. **Fetchers**: Use the same pipeline pattern for data sources -4. **Error Handling**: Create custom error types with helpful messages -5. **CLI Design**: Use `--key-id` and `--key-file` flags consistently -6. **Documentation**: Include comprehensive authentication setup section -7. **Security**: Create .gitignore immediately with `*.pem` entry - -### Reusable Patterns - -**BushelCloudKitService pattern** → Can be copied for Celestra -**DataSourcePipeline pattern** → Adapt for Celestra's data sources -**RecordBuilder pattern** → Reuse for Celestra's record types -**CLI structure** → Same flag naming and env var conventions - ---- - -## References - -- MistKit: `Sources/MistKit/Authentication/ServerToServerAuthManager.swift` -- CloudKit Schema: `Examples/Bushel/schema.ckdb` -- Setup Script: `Examples/Bushel/Scripts/setup-cloudkit-schema.sh` -- Pipeline: `Examples/Bushel/Sources/BushelImages/DataSources/DataSourcePipeline.swift` - ---- - -**Last Updated**: Current session -**Status**: AppleDB integration complete, S2S auth refactoring complete, ready for testing diff --git a/Examples/Bushel/Sources/BushelImages/BushelImagesCLI.swift b/Examples/Bushel/Sources/BushelImages/BushelImagesCLI.swift deleted file mode 100644 index d0ed0cc5..00000000 --- a/Examples/Bushel/Sources/BushelImages/BushelImagesCLI.swift +++ /dev/null @@ -1,24 +0,0 @@ -import ArgumentParser - -@main -internal struct BushelImagesCLI: AsyncParsableCommand { - internal static let configuration = CommandConfiguration( - commandName: "bushel-images", - abstract: "CloudKit version history tool for Bushel virtualization", - discussion: """ - A command-line tool demonstrating MistKit's CloudKit Web Services capabilities. - - Manages macOS restore images, Xcode versions, and Swift compiler versions - in CloudKit for use with Bushel's virtualization workflow. - """, - version: "1.0.0", - subcommands: [ - SyncCommand.self, - StatusCommand.self, - ListCommand.self, - ExportCommand.self, - ClearCommand.self - ], - defaultSubcommand: SyncCommand.self - ) -} diff --git a/Examples/Bushel/Sources/BushelImages/CloudKit/BushelCloudKitError.swift b/Examples/Bushel/Sources/BushelImages/CloudKit/BushelCloudKitError.swift deleted file mode 100644 index 84a6a1c7..00000000 --- a/Examples/Bushel/Sources/BushelImages/CloudKit/BushelCloudKitError.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -/// Errors that can occur during BushelCloudKitService operations -enum BushelCloudKitError: LocalizedError { - case privateKeyFileNotFound(path: String) - case privateKeyFileReadFailed(path: String, error: Error) - case invalidMetadataRecord(recordName: String) - - var errorDescription: String? { - switch self { - case .privateKeyFileNotFound(let path): - return "Private key file not found at path: \(path)" - case .privateKeyFileReadFailed(let path, let error): - return "Failed to read private key file at \(path): \(error.localizedDescription)" - case .invalidMetadataRecord(let recordName): - return "Invalid DataSourceMetadata record: \(recordName) (missing required fields)" - } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/CloudKit/BushelCloudKitService.swift b/Examples/Bushel/Sources/BushelImages/CloudKit/BushelCloudKitService.swift deleted file mode 100644 index 08dea06a..00000000 --- a/Examples/Bushel/Sources/BushelImages/CloudKit/BushelCloudKitService.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation -import MistKit - -/// CloudKit service wrapper for Bushel demo operations -/// -/// **Tutorial**: This demonstrates MistKit's Server-to-Server authentication pattern: -/// 1. Load ECDSA private key from .pem file -/// 2. Create ServerToServerAuthManager with key ID and PEM string -/// 3. Initialize CloudKitService with the auth manager -/// 4. Use service.modifyRecords() and service.queryRecords() for operations -/// -/// This pattern allows command-line tools and servers to access CloudKit without user authentication. -struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCollection { - private let service: CloudKitService - - // MARK: - CloudKitRecordCollection - - /// All CloudKit record types managed by this service (using variadic generics) - static let recordTypes = RecordTypeSet( - RestoreImageRecord.self, - XcodeVersionRecord.self, - SwiftVersionRecord.self, - DataSourceMetadata.self - ) - - // MARK: - Initialization - - /// Initialize CloudKit service with Server-to-Server authentication - /// - /// **MistKit Pattern**: Server-to-Server authentication requires: - /// 1. Key ID from CloudKit Dashboard → API Access → Server-to-Server Keys - /// 2. Private key .pem file downloaded when creating the key - /// 3. Container identifier (begins with "iCloud.") - /// - /// - Parameters: - /// - containerIdentifier: CloudKit container ID (e.g., "iCloud.com.company.App") - /// - keyID: Server-to-Server Key ID from CloudKit Dashboard - /// - privateKeyPath: Path to the private key .pem file - /// - Throws: Error if the private key file cannot be read or is invalid - init(containerIdentifier: String, keyID: String, privateKeyPath: String) throws { - // Read PEM file from disk - guard FileManager.default.fileExists(atPath: privateKeyPath) else { - throw BushelCloudKitError.privateKeyFileNotFound(path: privateKeyPath) - } - - let pemString: String - do { - pemString = try String(contentsOfFile: privateKeyPath, encoding: .utf8) - } catch { - throw BushelCloudKitError.privateKeyFileReadFailed(path: privateKeyPath, error: error) - } - - // Create Server-to-Server authentication manager - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: pemString - ) - - self.service = try CloudKitService( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: .development, - database: .public - ) - } - - // MARK: - RecordManaging Protocol Requirements - - /// Query all records of a given type - func queryRecords(recordType: String) async throws -> [RecordInfo] { - try await service.queryRecords(recordType: recordType, limit: 200) - } - - /// Execute operations in batches (CloudKit limits to 200 operations per request) - /// - /// **MistKit Pattern**: CloudKit has a 200 operations/request limit. - /// This method chunks operations and calls service.modifyRecords() for each batch. - func executeBatchOperations( - _ operations: [RecordOperation], - recordType: String - ) async throws { - let batchSize = 200 - let batches = operations.chunked(into: batchSize) - - print("Syncing \(operations.count) \(recordType) record(s) in \(batches.count) batch(es)...") - BushelLogger.verbose( - "CloudKit batch limit: 200 operations/request. Using \(batches.count) batch(es) for \(operations.count) records.", - subsystem: BushelLogger.cloudKit - ) - - var totalSucceeded = 0 - var totalFailed = 0 - - for (index, batch) in batches.enumerated() { - print(" Batch \(index + 1)/\(batches.count): \(batch.count) records...") - BushelLogger.verbose( - "Calling MistKit service.modifyRecords() with \(batch.count) RecordOperation objects", - subsystem: BushelLogger.cloudKit - ) - - let results = try await service.modifyRecords(batch) - - BushelLogger.verbose("Received \(results.count) RecordInfo responses from CloudKit", subsystem: BushelLogger.cloudKit) - - // Filter out error responses using isError property - let successfulRecords = results.filter { !$0.isError } - let failedCount = results.count - successfulRecords.count - - totalSucceeded += successfulRecords.count - totalFailed += failedCount - - if failedCount > 0 { - print(" ⚠️ \(failedCount) operations failed (see verbose logs for details)") - print(" ✓ \(successfulRecords.count) records confirmed") - - // Log error details in verbose mode - let errorRecords = results.filter { $0.isError } - for errorRecord in errorRecords { - BushelLogger.verbose( - "Error: recordName=\(errorRecord.recordName), reason=\(errorRecord.recordType)", - subsystem: BushelLogger.cloudKit - ) - } - } else { - BushelLogger.success("CloudKit confirmed \(successfulRecords.count) records", subsystem: BushelLogger.cloudKit) - } - } - - print("\n📊 \(recordType) Sync Summary:") - print(" Attempted: \(operations.count) operations") - print(" Succeeded: \(totalSucceeded) records") - - if totalFailed > 0 { - print(" ❌ Failed: \(totalFailed) operations") - BushelLogger.explain( - "Use --verbose flag to see CloudKit error details (serverErrorCode, reason, etc.)", - subsystem: BushelLogger.cloudKit - ) - } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/CloudKit/CloudKitFieldMapping.swift b/Examples/Bushel/Sources/BushelImages/CloudKit/CloudKitFieldMapping.swift deleted file mode 100644 index f06b15d0..00000000 --- a/Examples/Bushel/Sources/BushelImages/CloudKit/CloudKitFieldMapping.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -import MistKit - -/// Helper utilities for converting between Swift types and CloudKit FieldValue types -enum CloudKitFieldMapping { - /// Convert a String to FieldValue - static func fieldValue(from string: String) -> FieldValue { - .string(string) - } - - /// Convert an optional String to FieldValue - static func fieldValue(from string: String?) -> FieldValue? { - string.map { .string($0) } - } - - /// Convert a Bool to FieldValue (using INT64 representation: 0 = false, 1 = true) - static func fieldValue(from bool: Bool) -> FieldValue { - .from(bool) - } - - /// Convert an Int64 to FieldValue - static func fieldValue(from int64: Int64) -> FieldValue { - .int64(Int(int64)) - } - - /// Convert an optional Int64 to FieldValue - static func fieldValue(from int64: Int64?) -> FieldValue? { - int64.map { .int64(Int($0)) } - } - - /// Convert a Date to FieldValue - static func fieldValue(from date: Date) -> FieldValue { - .date(date) - } - - /// Convert a CloudKit reference (recordName) to FieldValue - static func referenceFieldValue(recordName: String) -> FieldValue { - .reference(FieldValue.Reference(recordName: recordName)) - } - - /// Convert an optional CloudKit reference to FieldValue - static func referenceFieldValue(recordName: String?) -> FieldValue? { - recordName.map { .reference(FieldValue.Reference(recordName: $0)) } - } - - /// Extract String from FieldValue - static func string(from fieldValue: FieldValue) -> String? { - if case .string(let value) = fieldValue { - return value - } - return nil - } - - /// Extract Bool from FieldValue (from INT64 representation: 0 = false, non-zero = true) - static func bool(from fieldValue: FieldValue) -> Bool? { - if case .int64(let value) = fieldValue { - return value != 0 - } - return nil - } - - /// Extract Int64 from FieldValue - static func int64(from fieldValue: FieldValue) -> Int64? { - if case .int64(let value) = fieldValue { - return Int64(value) - } - return nil - } - - /// Extract Date from FieldValue - static func date(from fieldValue: FieldValue) -> Date? { - if case .date(let value) = fieldValue { - return value - } - return nil - } - - /// Extract reference recordName from FieldValue - static func recordName(from fieldValue: FieldValue) -> String? { - if case .reference(let reference) = fieldValue { - return reference.recordName - } - return nil - } -} diff --git a/Examples/Bushel/Sources/BushelImages/CloudKit/RecordManaging+Query.swift b/Examples/Bushel/Sources/BushelImages/CloudKit/RecordManaging+Query.swift deleted file mode 100644 index c4f6e496..00000000 --- a/Examples/Bushel/Sources/BushelImages/CloudKit/RecordManaging+Query.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import MistKit - -extension RecordManaging { - // MARK: - Query Operations - - /// Query a specific DataSourceMetadata record - /// - /// **MistKit Pattern**: Query all metadata records and filter by record name - /// Record name format: "metadata-{sourceName}-{recordType}" - func queryDataSourceMetadata(source: String, recordType: String) async throws -> DataSourceMetadata? { - let targetRecordName = "metadata-\(source)-\(recordType)" - let results = try await query(DataSourceMetadata.self) { record in - record.recordName == targetRecordName - } - return results.first - } -} diff --git a/Examples/Bushel/Sources/BushelImages/CloudKit/SyncEngine.swift b/Examples/Bushel/Sources/BushelImages/CloudKit/SyncEngine.swift deleted file mode 100644 index 88e1699c..00000000 --- a/Examples/Bushel/Sources/BushelImages/CloudKit/SyncEngine.swift +++ /dev/null @@ -1,190 +0,0 @@ -import Foundation -import MistKit - -/// Orchestrates the complete sync process from data sources to CloudKit -/// -/// **Tutorial**: This demonstrates the typical flow for CloudKit data syncing: -/// 1. Fetch data from external sources -/// 2. Transform to CloudKit records -/// 3. Batch upload using MistKit -/// -/// Use `--verbose` flag to see detailed MistKit API usage. -struct SyncEngine: Sendable { - let cloudKitService: BushelCloudKitService - let pipeline: DataSourcePipeline - - // MARK: - Configuration - - struct SyncOptions: Sendable { - var dryRun: Bool = false - var pipelineOptions: DataSourcePipeline.Options = .init() - } - - // MARK: - Initialization - - init( - containerIdentifier: String, - keyID: String, - privateKeyPath: String, - configuration: FetchConfiguration = FetchConfiguration.loadFromEnvironment() - ) throws { - let service = try BushelCloudKitService( - containerIdentifier: containerIdentifier, - keyID: keyID, - privateKeyPath: privateKeyPath - ) - self.cloudKitService = service - self.pipeline = DataSourcePipeline( - cloudKitService: service, - configuration: configuration - ) - } - - // MARK: - Sync Operations - - /// Execute full sync from all data sources to CloudKit - func sync(options: SyncOptions = SyncOptions()) async throws -> SyncResult { - print("\n" + String(repeating: "=", count: 60)) - BushelLogger.info("🔄 Starting Bushel CloudKit Sync", subsystem: BushelLogger.sync) - print(String(repeating: "=", count: 60)) - - if options.dryRun { - BushelLogger.info("🧪 DRY RUN MODE - No changes will be made to CloudKit", subsystem: BushelLogger.sync) - } - - BushelLogger.explain( - "This sync demonstrates MistKit's Server-to-Server authentication and bulk record operations", - subsystem: BushelLogger.sync - ) - - // Step 1: Fetch from all data sources - print("\n📥 Step 1: Fetching data from external sources...") - BushelLogger.verbose("Initializing data source pipeline to fetch from ipsw.me, TheAppleWiki, MESU, and other sources", subsystem: BushelLogger.dataSource) - - let fetchResult = try await pipeline.fetch(options: options.pipelineOptions) - - BushelLogger.verbose("Data fetch complete. Beginning deduplication and merge phase.", subsystem: BushelLogger.dataSource) - BushelLogger.explain( - "Multiple data sources may have overlapping data. The pipeline deduplicates by version+build number.", - subsystem: BushelLogger.dataSource - ) - - let stats = SyncResult( - restoreImagesCount: fetchResult.restoreImages.count, - xcodeVersionsCount: fetchResult.xcodeVersions.count, - swiftVersionsCount: fetchResult.swiftVersions.count - ) - - let totalRecords = stats.restoreImagesCount + stats.xcodeVersionsCount + stats.swiftVersionsCount - - print("\n📊 Data Summary:") - print(" RestoreImages: \(stats.restoreImagesCount)") - print(" XcodeVersions: \(stats.xcodeVersionsCount)") - print(" SwiftVersions: \(stats.swiftVersionsCount)") - print(" ─────────────────────") - print(" Total: \(totalRecords) records") - - BushelLogger.verbose("Records ready for CloudKit upload: \(totalRecords) total", subsystem: BushelLogger.sync) - - // Step 2: Sync to CloudKit (unless dry run) - if !options.dryRun { - print("\n☁️ Step 2: Syncing to CloudKit...") - BushelLogger.verbose("Using MistKit to batch upload records to CloudKit public database", subsystem: BushelLogger.cloudKit) - BushelLogger.explain( - "MistKit handles authentication, batching (200 records/request), and error handling automatically", - subsystem: BushelLogger.cloudKit - ) - - // Sync in dependency order: SwiftVersion → RestoreImage → XcodeVersion - // (Prevents broken CKReference relationships) - try await cloudKitService.syncAllRecords( - fetchResult.swiftVersions, // First: no dependencies - fetchResult.restoreImages, // Second: no dependencies - fetchResult.xcodeVersions // Third: references first two - ) - } else { - print("\n⏭️ Step 2: Skipped (dry run)") - print(" Would sync:") - print(" • \(stats.restoreImagesCount) restore images") - print(" • \(stats.xcodeVersionsCount) Xcode versions") - print(" • \(stats.swiftVersionsCount) Swift versions") - BushelLogger.verbose("Dry run mode: No CloudKit operations performed", subsystem: BushelLogger.sync) - } - - print("\n" + String(repeating: "=", count: 60)) - BushelLogger.success("Sync completed successfully!", subsystem: BushelLogger.sync) - print(String(repeating: "=", count: 60)) - - return stats - } - - /// Delete all records from CloudKit - func clear() async throws { - print("\n" + String(repeating: "=", count: 60)) - BushelLogger.info("🗑️ Clearing all CloudKit data", subsystem: BushelLogger.cloudKit) - print(String(repeating: "=", count: 60)) - - try await cloudKitService.deleteAllRecords() - - print("\n" + String(repeating: "=", count: 60)) - BushelLogger.success("Clear completed successfully!", subsystem: BushelLogger.sync) - print(String(repeating: "=", count: 60)) - } - - /// Export all records from CloudKit to a structured format - func export() async throws -> ExportResult { - print("\n" + String(repeating: "=", count: 60)) - BushelLogger.info("📤 Exporting data from CloudKit", subsystem: BushelLogger.cloudKit) - print(String(repeating: "=", count: 60)) - - BushelLogger.explain( - "Using MistKit's queryRecords() to fetch all records of each type from the public database", - subsystem: BushelLogger.cloudKit - ) - - print("\n📥 Fetching RestoreImage records...") - BushelLogger.verbose("Querying CloudKit for recordType: 'RestoreImage' with limit: 1000", subsystem: BushelLogger.cloudKit) - let restoreImages = try await cloudKitService.queryRecords(recordType: "RestoreImage") - BushelLogger.verbose("Retrieved \(restoreImages.count) RestoreImage records", subsystem: BushelLogger.cloudKit) - - print("📥 Fetching XcodeVersion records...") - BushelLogger.verbose("Querying CloudKit for recordType: 'XcodeVersion' with limit: 1000", subsystem: BushelLogger.cloudKit) - let xcodeVersions = try await cloudKitService.queryRecords(recordType: "XcodeVersion") - BushelLogger.verbose("Retrieved \(xcodeVersions.count) XcodeVersion records", subsystem: BushelLogger.cloudKit) - - print("📥 Fetching SwiftVersion records...") - BushelLogger.verbose("Querying CloudKit for recordType: 'SwiftVersion' with limit: 1000", subsystem: BushelLogger.cloudKit) - let swiftVersions = try await cloudKitService.queryRecords(recordType: "SwiftVersion") - BushelLogger.verbose("Retrieved \(swiftVersions.count) SwiftVersion records", subsystem: BushelLogger.cloudKit) - - print("\n✅ Exported:") - print(" • \(restoreImages.count) restore images") - print(" • \(xcodeVersions.count) Xcode versions") - print(" • \(swiftVersions.count) Swift versions") - - BushelLogger.explain( - "MistKit returns RecordInfo structs with record metadata. Use .fields to access CloudKit field values.", - subsystem: BushelLogger.cloudKit - ) - - return ExportResult( - restoreImages: restoreImages, - xcodeVersions: xcodeVersions, - swiftVersions: swiftVersions - ) - } - - // MARK: - Result Types - - struct SyncResult: Sendable { - let restoreImagesCount: Int - let xcodeVersionsCount: Int - let swiftVersionsCount: Int - } - - struct ExportResult { - let restoreImages: [RecordInfo] - let xcodeVersions: [RecordInfo] - let swiftVersions: [RecordInfo] - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Commands/ClearCommand.swift b/Examples/Bushel/Sources/BushelImages/Commands/ClearCommand.swift deleted file mode 100644 index dd241d09..00000000 --- a/Examples/Bushel/Sources/BushelImages/Commands/ClearCommand.swift +++ /dev/null @@ -1,113 +0,0 @@ -import ArgumentParser -import Foundation - -struct ClearCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "clear", - abstract: "Delete all records from CloudKit", - discussion: """ - Deletes all RestoreImage, XcodeVersion, and SwiftVersion records from - the CloudKit public database. - - ⚠️ WARNING: This operation cannot be undone! - """ - ) - - // MARK: - Required Options - - @Option(name: .shortAndLong, help: "CloudKit container identifier") - var containerIdentifier: String = "iCloud.com.brightdigit.Bushel" - - @Option(name: .long, help: "Server-to-Server Key ID (or set CLOUDKIT_KEY_ID)") - var keyID: String = "" - - @Option(name: .long, help: "Path to private key .pem file (or set CLOUDKIT_PRIVATE_KEY_PATH)") - var keyFile: String = "" - - // MARK: - Options - - @Flag(name: .shortAndLong, help: "Skip confirmation prompt") - var yes: Bool = false - - @Flag(name: .shortAndLong, help: "Enable verbose logging to see detailed CloudKit operations and learn MistKit usage patterns") - var verbose: Bool = false - - // MARK: - Execution - - mutating func run() async throws { - // Enable verbose logging if requested - BushelLogger.isVerbose = verbose - - // Get Server-to-Server credentials from environment if not provided - let resolvedKeyID = keyID.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"] ?? "" : - keyID - - let resolvedKeyFile = keyFile.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_PRIVATE_KEY_PATH"] ?? "" : - keyFile - - guard !resolvedKeyID.isEmpty && !resolvedKeyFile.isEmpty else { - print("❌ Error: CloudKit Server-to-Server Key credentials are required") - print("") - print(" Provide via command-line flags:") - print(" --key-id YOUR_KEY_ID --key-file ./private-key.pem") - print("") - print(" Or set environment variables:") - print(" export CLOUDKIT_KEY_ID=\"YOUR_KEY_ID\"") - print(" export CLOUDKIT_PRIVATE_KEY_PATH=\"./private-key.pem\"") - print("") - print(" Get your Server-to-Server Key from:") - print(" https://icloud.developer.apple.com/dashboard/") - print(" Navigate to: API Access → Server-to-Server Keys") - print("") - print(" Important:") - print(" • Download and save the private key .pem file securely") - print(" • Never commit .pem files to version control!") - print("") - throw ExitCode.failure - } - - // Confirm deletion unless --yes flag is provided - if !yes { - print("\n⚠️ WARNING: This will delete ALL records from CloudKit!") - print(" Container: \(containerIdentifier)") - print(" Database: public (development)") - print("") - print(" This operation cannot be undone.") - print("") - print(" Type 'yes' to confirm: ", terminator: "") - - guard let response = readLine(), response.lowercased() == "yes" else { - print("\n❌ Operation cancelled") - throw ExitCode.failure - } - } - - // Create sync engine - let syncEngine = try SyncEngine( - containerIdentifier: containerIdentifier, - keyID: resolvedKeyID, - privateKeyPath: resolvedKeyFile - ) - - // Execute clear - do { - try await syncEngine.clear() - print("\n✅ All records have been deleted from CloudKit") - } catch { - printError(error) - throw ExitCode.failure - } - } - - // MARK: - Private Helpers - - private func printError(_ error: Error) { - print("\n❌ Clear failed: \(error.localizedDescription)") - print("\n💡 Troubleshooting:") - print(" • Verify your API token is valid") - print(" • Check your internet connection") - print(" • Ensure the CloudKit container exists") - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Commands/ExportCommand.swift b/Examples/Bushel/Sources/BushelImages/Commands/ExportCommand.swift deleted file mode 100644 index 4d7354ce..00000000 --- a/Examples/Bushel/Sources/BushelImages/Commands/ExportCommand.swift +++ /dev/null @@ -1,210 +0,0 @@ -import ArgumentParser -import Foundation -import MistKit - -struct ExportCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "export", - abstract: "Export CloudKit data to JSON", - discussion: """ - Queries the CloudKit public database and exports all version records - to JSON format for analysis or backup. - """ - ) - - // MARK: - Required Options - - @Option(name: .shortAndLong, help: "CloudKit container identifier") - var containerIdentifier: String = "iCloud.com.brightdigit.Bushel" - - @Option(name: .long, help: "Server-to-Server Key ID (or set CLOUDKIT_KEY_ID)") - var keyID: String = "" - - @Option(name: .long, help: "Path to private key .pem file (or set CLOUDKIT_PRIVATE_KEY_PATH)") - var keyFile: String = "" - - // MARK: - Export Options - - @Option(name: .shortAndLong, help: "Output file path (default: stdout)") - var output: String? - - @Flag(name: .long, help: "Pretty-print JSON output") - var pretty: Bool = false - - @Flag(name: .long, help: "Export only signed restore images") - var signedOnly: Bool = false - - @Flag(name: .long, help: "Exclude beta/RC releases") - var noBetas: Bool = false - - @Flag(name: .shortAndLong, help: "Enable verbose logging to see detailed CloudKit operations and learn MistKit usage patterns") - var verbose: Bool = false - - // MARK: - Execution - - mutating func run() async throws { - // Enable verbose logging if requested - BushelLogger.isVerbose = verbose - - // Get Server-to-Server credentials from environment if not provided - let resolvedKeyID = keyID.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"] ?? "" : - keyID - - let resolvedKeyFile = keyFile.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_PRIVATE_KEY_PATH"] ?? "" : - keyFile - - guard !resolvedKeyID.isEmpty && !resolvedKeyFile.isEmpty else { - print("❌ Error: CloudKit Server-to-Server Key credentials are required") - print("") - print(" Provide via command-line flags:") - print(" --key-id YOUR_KEY_ID --key-file ./private-key.pem") - print("") - print(" Or set environment variables:") - print(" export CLOUDKIT_KEY_ID=\"YOUR_KEY_ID\"") - print(" export CLOUDKIT_PRIVATE_KEY_PATH=\"./private-key.pem\"") - print("") - print(" Get your Server-to-Server Key from:") - print(" https://icloud.developer.apple.com/dashboard/") - print(" Navigate to: API Access → Server-to-Server Keys") - print("") - print(" Important:") - print(" • Download and save the private key .pem file securely") - print(" • Never commit .pem files to version control!") - print("") - throw ExitCode.failure - } - - // Create sync engine - let syncEngine = try SyncEngine( - containerIdentifier: containerIdentifier, - keyID: resolvedKeyID, - privateKeyPath: resolvedKeyFile - ) - - // Execute export - do { - let result = try await syncEngine.export() - let filtered = applyFilters(to: result) - let json = try encodeToJSON(filtered) - - if let outputPath = output { - try writeToFile(json, at: outputPath) - print("✅ Exported to: \(outputPath)") - } else { - print(json) - } - } catch { - printError(error) - throw ExitCode.failure - } - } - - // MARK: - Private Helpers - - private func applyFilters(to result: SyncEngine.ExportResult) -> SyncEngine.ExportResult { - var restoreImages = result.restoreImages - var xcodeVersions = result.xcodeVersions - var swiftVersions = result.swiftVersions - - // Filter signed-only restore images - if signedOnly { - restoreImages = restoreImages.filter { record in - if case .int64(let isSigned) = record.fields["isSigned"] { - return isSigned != 0 - } - return false - } - } - - // Filter out betas - if noBetas { - restoreImages = restoreImages.filter { record in - if case .int64(let isPrerelease) = record.fields["isPrerelease"] { - return isPrerelease == 0 - } - return true - } - - xcodeVersions = xcodeVersions.filter { record in - if case .int64(let isPrerelease) = record.fields["isPrerelease"] { - return isPrerelease == 0 - } - return true - } - - swiftVersions = swiftVersions.filter { record in - if case .int64(let isPrerelease) = record.fields["isPrerelease"] { - return isPrerelease == 0 - } - return true - } - } - - return SyncEngine.ExportResult( - restoreImages: restoreImages, - xcodeVersions: xcodeVersions, - swiftVersions: swiftVersions - ) - } - - private func encodeToJSON(_ result: SyncEngine.ExportResult) throws -> String { - let export = ExportData( - restoreImages: result.restoreImages.map(RecordExport.init), - xcodeVersions: result.xcodeVersions.map(RecordExport.init), - swiftVersions: result.swiftVersions.map(RecordExport.init) - ) - - let encoder = JSONEncoder() - if pretty { - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - - let data = try encoder.encode(export) - guard let json = String(data: data, encoding: .utf8) else { - throw ExportError.encodingFailed - } - - return json - } - - private func writeToFile(_ content: String, at path: String) throws { - try content.write(toFile: path, atomically: true, encoding: .utf8) - } - - private func printError(_ error: Error) { - print("\n❌ Export failed: \(error.localizedDescription)") - print("\n💡 Troubleshooting:") - print(" • Verify your API token is valid") - print(" • Check your internet connection") - print(" • Ensure data has been synced to CloudKit") - print(" • Run 'bushel-images sync' first if needed") - } - - // MARK: - Export Types - - struct ExportData: Codable { - let restoreImages: [RecordExport] - let xcodeVersions: [RecordExport] - let swiftVersions: [RecordExport] - } - - struct RecordExport: Codable { - let recordName: String - let recordType: String - let fields: [String: String] - - init(from recordInfo: RecordInfo) { - self.recordName = recordInfo.recordName - self.recordType = recordInfo.recordType - self.fields = recordInfo.fields.mapValues { fieldValue in - String(describing: fieldValue) - } - } - } - - enum ExportError: Error { - case encodingFailed - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Commands/ListCommand.swift b/Examples/Bushel/Sources/BushelImages/Commands/ListCommand.swift deleted file mode 100644 index a0399164..00000000 --- a/Examples/Bushel/Sources/BushelImages/Commands/ListCommand.swift +++ /dev/null @@ -1,100 +0,0 @@ -// ListCommand.swift -// Created by Claude Code - -import ArgumentParser -import Foundation -import MistKit - -struct ListCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List CloudKit records", - discussion: """ - Displays all records stored in CloudKit across different record types. - - By default, lists all record types. Use flags to show specific types only. - """ - ) - - // MARK: - Required Options - - @Option(name: .shortAndLong, help: "CloudKit container identifier") - var containerIdentifier: String = "iCloud.com.brightdigit.Bushel" - - @Option(name: .long, help: "Server-to-Server Key ID (or set CLOUDKIT_KEY_ID)") - var keyID: String = "" - - @Option(name: .long, help: "Path to private key .pem file (or set CLOUDKIT_PRIVATE_KEY_PATH)") - var keyFile: String = "" - - // MARK: - Filter Options - - @Flag(name: .long, help: "List only restore images") - var restoreImages: Bool = false - - @Flag(name: .long, help: "List only Xcode versions") - var xcodeVersions: Bool = false - - @Flag(name: .long, help: "List only Swift versions") - var swiftVersions: Bool = false - - // MARK: - Display Options - - @Flag(name: .long, help: "Disable log redaction for debugging") - var noRedaction: Bool = false - - // MARK: - Execution - - mutating func run() async throws { - // Disable log redaction for debugging if requested - if noRedaction { - setenv("MISTKIT_DISABLE_LOG_REDACTION", "1", 1) - } - - // Get Server-to-Server credentials from environment if not provided - let resolvedKeyID = keyID.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"] ?? "" : - keyID - - let resolvedKeyFile = keyFile.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_PRIVATE_KEY_PATH"] ?? "" : - keyFile - - guard !resolvedKeyID.isEmpty, !resolvedKeyFile.isEmpty else { - print("❌ Error: CloudKit Server-to-Server Key credentials are required") - print("") - print(" Provide via command-line flags:") - print(" --key-id YOUR_KEY_ID --key-file ./private-key.pem") - print("") - print(" Or set environment variables:") - print(" export CLOUDKIT_KEY_ID=\"YOUR_KEY_ID\"") - print(" export CLOUDKIT_PRIVATE_KEY_PATH=\"./private-key.pem\"") - print("") - throw ExitCode.failure - } - - // Create CloudKit service - let cloudKitService = try BushelCloudKitService( - containerIdentifier: containerIdentifier, - keyID: resolvedKeyID, - privateKeyPath: resolvedKeyFile - ) - - // Determine what to list based on flags - let listAll = !restoreImages && !xcodeVersions && !swiftVersions - - if listAll { - try await cloudKitService.listAllRecords() - } else { - if restoreImages { - try await cloudKitService.list(RestoreImageRecord.self) - } - if xcodeVersions { - try await cloudKitService.list(XcodeVersionRecord.self) - } - if swiftVersions { - try await cloudKitService.list(SwiftVersionRecord.self) - } - } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Commands/SyncCommand.swift b/Examples/Bushel/Sources/BushelImages/Commands/SyncCommand.swift deleted file mode 100644 index 11f7639d..00000000 --- a/Examples/Bushel/Sources/BushelImages/Commands/SyncCommand.swift +++ /dev/null @@ -1,202 +0,0 @@ -import ArgumentParser -import Foundation - -struct SyncCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "sync", - abstract: "Fetch version data and sync to CloudKit", - discussion: """ - Fetches macOS restore images, Xcode versions, and Swift versions from - external data sources and syncs them to the CloudKit public database. - - Data sources: - • RestoreImage: ipsw.me, TheAppleWiki.com, Mr. Macintosh, Apple MESU - • XcodeVersion: xcodereleases.com - • SwiftVersion: swiftversion.net - """ - ) - - // MARK: - Required Options - - @Option(name: .shortAndLong, help: "CloudKit container identifier") - var containerIdentifier: String = "iCloud.com.brightdigit.Bushel" - - @Option(name: .long, help: "Server-to-Server Key ID (or set CLOUDKIT_KEY_ID)") - var keyID: String = "" - - @Option(name: .long, help: "Path to private key .pem file (or set CLOUDKIT_PRIVATE_KEY_PATH)") - var keyFile: String = "" - - // MARK: - Sync Options - - @Flag(name: .long, help: "Perform a dry run without syncing to CloudKit") - var dryRun: Bool = false - - @Flag(name: .long, help: "Sync only restore images") - var restoreImagesOnly: Bool = false - - @Flag(name: .long, help: "Sync only Xcode versions") - var xcodeOnly: Bool = false - - @Flag(name: .long, help: "Sync only Swift versions") - var swiftOnly: Bool = false - - @Flag(name: .long, help: "Exclude beta/RC releases") - var noBetas: Bool = false - - @Flag(name: .long, help: "Exclude TheAppleWiki.com as data source") - var noAppleWiki: Bool = false - - @Flag(name: .shortAndLong, help: "Enable verbose logging to see detailed CloudKit operations and learn MistKit usage patterns") - var verbose: Bool = false - - @Flag(name: .long, help: "Disable log redaction for debugging (shows actual CloudKit field names in errors)") - var noRedaction: Bool = false - - // MARK: - Throttling Options - - @Flag(name: .long, help: "Force fetch from all sources, ignoring minimum fetch intervals") - var force: Bool = false - - @Option(name: .long, help: "Minimum interval between fetches in seconds (overrides default intervals)") - var minInterval: Int? - - @Option(name: .long, help: "Fetch from only this specific source (e.g., 'appledb.dev', 'ipsw.me')") - var source: String? - - // MARK: - Execution - - mutating func run() async throws { - // Enable verbose logging if requested - BushelLogger.isVerbose = verbose - - // Disable log redaction for debugging if requested - if noRedaction { - setenv("MISTKIT_DISABLE_LOG_REDACTION", "1", 1) - } - - // Get Server-to-Server credentials from environment if not provided - let resolvedKeyID = keyID.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"] ?? "" : - keyID - - let resolvedKeyFile = keyFile.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_PRIVATE_KEY_PATH"] ?? "" : - keyFile - - guard !resolvedKeyID.isEmpty && !resolvedKeyFile.isEmpty else { - print("❌ Error: CloudKit Server-to-Server Key credentials are required") - print("") - print(" Provide via command-line flags:") - print(" --key-id YOUR_KEY_ID --key-file ./private-key.pem") - print("") - print(" Or set environment variables:") - print(" export CLOUDKIT_KEY_ID=\"YOUR_KEY_ID\"") - print(" export CLOUDKIT_PRIVATE_KEY_PATH=\"./private-key.pem\"") - print("") - print(" Get your Server-to-Server Key from:") - print(" https://icloud.developer.apple.com/dashboard/") - print(" Navigate to: API Access → Server-to-Server Keys") - print("") - print(" Important:") - print(" • Download and save the private key .pem file securely") - print(" • Never commit .pem files to version control!") - print("") - throw ExitCode.failure - } - - // Determine what to sync - let options = buildSyncOptions() - - // Build fetch configuration - let configuration = buildFetchConfiguration() - - // Create sync engine - let syncEngine = try SyncEngine( - containerIdentifier: containerIdentifier, - keyID: resolvedKeyID, - privateKeyPath: resolvedKeyFile, - configuration: configuration - ) - - // Execute sync - do { - let result = try await syncEngine.sync(options: options) - printSuccess(result) - } catch { - printError(error) - throw ExitCode.failure - } - } - - // MARK: - Private Helpers - - private func buildSyncOptions() -> SyncEngine.SyncOptions { - var pipelineOptions = DataSourcePipeline.Options() - - // Apply filters based on flags - if restoreImagesOnly { - pipelineOptions.includeXcodeVersions = false - pipelineOptions.includeSwiftVersions = false - } else if xcodeOnly { - pipelineOptions.includeRestoreImages = false - pipelineOptions.includeSwiftVersions = false - } else if swiftOnly { - pipelineOptions.includeRestoreImages = false - pipelineOptions.includeXcodeVersions = false - } - - if noBetas { - pipelineOptions.includeBetaReleases = false - } - - if noAppleWiki { - pipelineOptions.includeTheAppleWiki = false - } - - // Apply throttling options - pipelineOptions.force = force - pipelineOptions.specificSource = source - - return SyncEngine.SyncOptions( - dryRun: dryRun, - pipelineOptions: pipelineOptions - ) - } - - private func buildFetchConfiguration() -> FetchConfiguration { - // Load configuration from environment - var config = FetchConfiguration.loadFromEnvironment() - - // Override with command-line flag if provided - if let interval = minInterval { - config = FetchConfiguration( - globalMinimumFetchInterval: TimeInterval(interval), - perSourceIntervals: config.perSourceIntervals, - useDefaults: true - ) - } - - return config - } - - private func printSuccess(_ result: SyncEngine.SyncResult) { - print("\n" + String(repeating: "=", count: 60)) - print("✅ Sync Summary") - print(String(repeating: "=", count: 60)) - print("Restore Images: \(result.restoreImagesCount)") - print("Xcode Versions: \(result.xcodeVersionsCount)") - print("Swift Versions: \(result.swiftVersionsCount)") - print(String(repeating: "=", count: 60)) - print("\n💡 Next: Use 'bushel-images export' to view the synced data") - } - - private func printError(_ error: Error) { - print("\n❌ Sync failed: \(error.localizedDescription)") - print("\n💡 Troubleshooting:") - print(" • Verify your API token is valid") - print(" • Check your internet connection") - print(" • Ensure the CloudKit container exists") - print(" • Verify external data sources are accessible") - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Configuration/FetchConfiguration.swift b/Examples/Bushel/Sources/BushelImages/Configuration/FetchConfiguration.swift deleted file mode 100644 index 39308214..00000000 --- a/Examples/Bushel/Sources/BushelImages/Configuration/FetchConfiguration.swift +++ /dev/null @@ -1,121 +0,0 @@ -// FetchConfiguration.swift -// Created by Claude Code - -import Foundation - -/// Configuration for data source fetch throttling -internal struct FetchConfiguration: Codable, Sendable { - // MARK: - Properties - - /// Global minimum interval between fetches (applies to all sources unless overridden) - let globalMinimumFetchInterval: TimeInterval? - - /// Per-source minimum intervals (overrides global and default intervals) - /// Key is the source name (e.g., "appledb.dev", "ipsw.me") - let perSourceIntervals: [String: TimeInterval] - - /// Whether to use default intervals for known sources - let useDefaults: Bool - - // MARK: - Initialization - - init( - globalMinimumFetchInterval: TimeInterval? = nil, - perSourceIntervals: [String: TimeInterval] = [:], - useDefaults: Bool = true - ) { - self.globalMinimumFetchInterval = globalMinimumFetchInterval - self.perSourceIntervals = perSourceIntervals - self.useDefaults = useDefaults - } - - // MARK: - Methods - - /// Get the minimum fetch interval for a specific source - /// - Parameter source: The source name (e.g., "appledb.dev") - /// - Returns: The minimum interval in seconds, or nil if no restrictions - func minimumInterval(for source: String) -> TimeInterval? { - // Priority: per-source > global > defaults - if let perSourceInterval = perSourceIntervals[source] { - return perSourceInterval - } - - if let globalInterval = globalMinimumFetchInterval { - return globalInterval - } - - if useDefaults { - return Self.defaultIntervals[source] - } - - return nil - } - - /// Should this source be fetched given the last fetch time? - /// - Parameters: - /// - source: The source name - /// - lastFetchedAt: When the source was last fetched (nil means never fetched) - /// - force: Whether to ignore intervals and fetch anyway - /// - Returns: True if the source should be fetched - func shouldFetch( - source: String, - lastFetchedAt: Date?, - force: Bool = false - ) -> Bool { - // Always fetch if force flag is set - if force { return true } - - // Always fetch if never fetched before - guard let lastFetch = lastFetchedAt else { return true } - - // Check if enough time has passed since last fetch - guard let minInterval = minimumInterval(for: source) else { return true } - - let timeSinceLastFetch = Date().timeIntervalSince(lastFetch) - return timeSinceLastFetch >= minInterval - } - - // MARK: - Default Intervals - - /// Default minimum intervals for known sources (in seconds) - static let defaultIntervals: [String: TimeInterval] = [ - // Restore Image Sources - "appledb.dev": 6 * 3600, // 6 hours - frequently updated - "ipsw.me": 12 * 3600, // 12 hours - less frequent updates - "mesu.apple.com": 1 * 3600, // 1 hour - signing status changes frequently - "mrmacintosh.com": 12 * 3600, // 12 hours - manual updates - "theapplewiki.com": 24 * 3600, // 24 hours - deprecated, rarely updated - - // Version Sources - "xcodereleases.com": 12 * 3600, // 12 hours - Xcode releases - "swiftversion.net": 12 * 3600, // 12 hours - Swift releases - ] - - // MARK: - Factory Methods - - /// Load configuration from environment variables - /// - Returns: Configuration with values from environment, or defaults - static func loadFromEnvironment() -> FetchConfiguration { - var perSourceIntervals: [String: TimeInterval] = [:] - - // Check for per-source environment variables (e.g., BUSHEL_FETCH_INTERVAL_APPLEDB) - for (source, _) in defaultIntervals { - let envKey = "BUSHEL_FETCH_INTERVAL_\(source.uppercased().replacingOccurrences(of: ".", with: "_"))" - if let intervalString = ProcessInfo.processInfo.environment[envKey], - let interval = TimeInterval(intervalString) - { - perSourceIntervals[source] = interval - } - } - - // Check for global interval - let globalInterval = ProcessInfo.processInfo.environment["BUSHEL_FETCH_INTERVAL_GLOBAL"] - .flatMap { TimeInterval($0) } - - return FetchConfiguration( - globalMinimumFetchInterval: globalInterval, - perSourceIntervals: perSourceIntervals, - useDefaults: true - ) - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBEntry.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBEntry.swift deleted file mode 100644 index 77e2f544..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBEntry.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -/// Represents a single macOS build entry from AppleDB -struct AppleDBEntry: Codable { - let osStr: String - let version: String - let build: String? // Some entries may not have a build number - let uniqueBuild: String? - let released: String // ISO date or empty string - let beta: Bool? - let rc: Bool? - let `internal`: Bool? - let deviceMap: [String] - let signed: SignedStatus - let sources: [AppleDBSource]? - - enum CodingKeys: String, CodingKey { - case osStr, version, build, uniqueBuild, released - case beta, rc - case `internal` = "internal" - case deviceMap, signed, sources - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBFetcher.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBFetcher.swift deleted file mode 100644 index 0a4fc937..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBFetcher.swift +++ /dev/null @@ -1,144 +0,0 @@ -import Foundation - -/// Fetcher for macOS restore images using AppleDB API -struct AppleDBFetcher: DataSourceFetcher, Sendable { - typealias Record = [RestoreImageRecord] - private let deviceIdentifier = "VirtualMac2,1" - - /// Fetch all VirtualMac2,1 restore images from AppleDB - func fetch() async throws -> [RestoreImageRecord] { - // Fetch when macOS data was last updated using GitHub API - let sourceUpdatedAt = await Self.fetchGitHubLastCommitDate() - - // Fetch AppleDB data - let entries = try await Self.fetchAppleDBData() - - // Filter for VirtualMac2,1 and map to RestoreImageRecord - return entries - .filter { $0.deviceMap.contains(deviceIdentifier) } - .compactMap { entry in - Self.mapToRestoreImage(entry: entry, sourceUpdatedAt: sourceUpdatedAt, deviceIdentifier: deviceIdentifier) - } - } - - // MARK: - Private Methods - - /// Fetch the last commit date for macOS data from GitHub API - private static func fetchGitHubLastCommitDate() async -> Date? { - do { - let url = URL(string: "https://api.github.com/repos/littlebyteorg/appledb/commits?path=osFiles/macOS&per_page=1")! - - let (data, _) = try await URLSession.shared.data(from: url) - - let commits = try JSONDecoder().decode([GitHubCommitsResponse].self, from: data) - - guard let firstCommit = commits.first else { - BushelLogger.warning("No commits found in AppleDB GitHub repository", subsystem: BushelLogger.dataSource) - return nil - } - - // Parse ISO 8601 date - let isoFormatter = ISO8601DateFormatter() - guard let date = isoFormatter.date(from: firstCommit.commit.committer.date) else { - BushelLogger.warning("Failed to parse commit date: \(firstCommit.commit.committer.date)", subsystem: BushelLogger.dataSource) - return nil - } - - BushelLogger.verbose("AppleDB macOS data last updated: \(date) (commit: \(firstCommit.sha.prefix(7)))", subsystem: BushelLogger.dataSource) - return date - - } catch { - BushelLogger.warning("Failed to fetch GitHub commit date for AppleDB: \(error)", subsystem: BushelLogger.dataSource) - // Fallback to HTTP Last-Modified header - let appleDBURL = URL(string: "https://api.appledb.dev/ios/macOS/main.json")! - return await HTTPHeaderHelpers.fetchLastModified(from: appleDBURL) - } - } - - /// Fetch macOS data from AppleDB API - private static func fetchAppleDBData() async throws -> [AppleDBEntry] { - let url = URL(string: "https://api.appledb.dev/ios/macOS/main.json")! - - BushelLogger.verbose("Fetching AppleDB data from \(url)", subsystem: BushelLogger.dataSource) - - let (data, _) = try await URLSession.shared.data(from: url) - - let entries = try JSONDecoder().decode([AppleDBEntry].self, from: data) - - BushelLogger.verbose("Fetched \(entries.count) total entries from AppleDB", subsystem: BushelLogger.dataSource) - - return entries - } - - /// Map an AppleDB entry to RestoreImageRecord - private static func mapToRestoreImage(entry: AppleDBEntry, sourceUpdatedAt: Date?, deviceIdentifier: String) -> RestoreImageRecord? { - // Skip entries without a build number (required for unique identification) - guard let build = entry.build else { - BushelLogger.verbose("Skipping AppleDB entry without build number: \(entry.version)", subsystem: BushelLogger.dataSource) - return nil - } - - // Determine if signed for VirtualMac2,1 - let isSigned = entry.signed.isSigned(for: deviceIdentifier) - - // Determine if prerelease - let isPrerelease = entry.beta == true || entry.rc == true || entry.internal == true - - // Parse release date if available - let releaseDate: Date? - if !entry.released.isEmpty { - let isoFormatter = ISO8601DateFormatter() - releaseDate = isoFormatter.date(from: entry.released) - } else { - releaseDate = nil - } - - // Find IPSW source - guard let ipswSource = entry.sources?.first(where: { $0.type == "ipsw" }) else { - BushelLogger.verbose("No IPSW source found for build \(build)", subsystem: BushelLogger.dataSource) - return nil - } - - // Get preferred or first active link - guard let link = ipswSource.links?.first(where: { $0.preferred == true || $0.active == true }) else { - BushelLogger.verbose("No active download link found for build \(build)", subsystem: BushelLogger.dataSource) - return nil - } - - return RestoreImageRecord( - version: entry.version, - buildNumber: build, - releaseDate: releaseDate ?? Date(), // Fallback to current date - downloadURL: link.url, - fileSize: ipswSource.size ?? 0, - sha256Hash: ipswSource.hashes?.sha2_256 ?? "", - sha1Hash: ipswSource.hashes?.sha1 ?? "", - isSigned: isSigned, - isPrerelease: isPrerelease, - source: "appledb.dev", - notes: "Device-specific signing status from AppleDB", - sourceUpdatedAt: sourceUpdatedAt - ) - } -} - -// MARK: - Error Types - -extension AppleDBFetcher { - enum FetchError: LocalizedError { - case invalidURL - case noDataFound - case decodingFailed(Error) - - var errorDescription: String? { - switch self { - case .invalidURL: - return "Invalid AppleDB URL" - case .noDataFound: - return "No data found from AppleDB" - case .decodingFailed(let error): - return "Failed to decode AppleDB response: \(error.localizedDescription)" - } - } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBHashes.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBHashes.swift deleted file mode 100644 index 254a57d0..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBHashes.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -/// Represents file hashes for verification -struct AppleDBHashes: Codable { - let sha1: String? - let sha2_256: String? // JSON key is "sha2-256" - - enum CodingKeys: String, CodingKey { - case sha1 - case sha2_256 = "sha2-256" - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBLink.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBLink.swift deleted file mode 100644 index 2fc170cc..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBLink.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -/// Represents a download link for a source -struct AppleDBLink: Codable { - let url: String - let preferred: Bool? - let active: Bool? -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBSource.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBSource.swift deleted file mode 100644 index 4ee9ab20..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/AppleDBSource.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -/// Represents an installation source (IPSW, OTA, or IA) -struct AppleDBSource: Codable { - let type: String // "ipsw", "ota", "ia" - let deviceMap: [String] - let links: [AppleDBLink]? - let hashes: AppleDBHashes? - let size: Int? - let prerequisiteBuild: String? -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommit.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommit.swift deleted file mode 100644 index 10cb550c..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommit.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -/// Represents a commit in GitHub API response -struct GitHubCommit: Codable { - let committer: GitHubCommitter - let message: String -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommitsResponse.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommitsResponse.swift deleted file mode 100644 index c6c7a6d1..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommitsResponse.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -/// Response from GitHub API for commits -struct GitHubCommitsResponse: Codable { - let sha: String - let commit: GitHubCommit -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommitter.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommitter.swift deleted file mode 100644 index ee07dfea..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/GitHubCommitter.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -/// Represents a committer in GitHub API response -struct GitHubCommitter: Codable { - let date: String // ISO 8601 format -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/SignedStatus.swift b/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/SignedStatus.swift deleted file mode 100644 index d461b16d..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/AppleDB/SignedStatus.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -/// Represents the signing status for a build -/// Can be: array of device IDs, boolean true (all signed), or empty array (none signed) -enum SignedStatus: Codable { - case devices([String]) // Array of signed device IDs - case all(Bool) // true = all devices signed - case none // Empty array = not signed - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - // Try decoding as array first - if let devices = try? container.decode([String].self) { - if devices.isEmpty { - self = .none - } else { - self = .devices(devices) - } - } - // Then try boolean - else if let allSigned = try? container.decode(Bool.self) { - self = .all(allSigned) - } - // Default to none if decoding fails - else { - self = .none - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .devices(let devices): - try container.encode(devices) - case .all(let value): - try container.encode(value) - case .none: - try container.encode([String]()) - } - } - - /// Check if a specific device identifier is signed - func isSigned(for deviceIdentifier: String) -> Bool { - switch self { - case .devices(let devices): - return devices.contains(deviceIdentifier) - case .all(true): - return true - case .all(false), .none: - return false - } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/DataSourceFetcher.swift b/Examples/Bushel/Sources/BushelImages/DataSources/DataSourceFetcher.swift deleted file mode 100644 index a23d0946..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/DataSourceFetcher.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation - -/// Protocol for data source fetchers that retrieve records from external APIs -/// -/// This protocol provides a common interface for all data source fetchers in the Bushel pipeline. -/// Fetchers are responsible for retrieving data from external sources and converting them to -/// typed record models. -/// -/// ## Implementation Requirements -/// - Must be `Sendable` to support concurrent fetching -/// - Should use `HTTPHeaderHelpers.fetchLastModified()` when available to track source freshness -/// - Should log warnings for missing or malformed data using `BushelLogger.dataSource` -/// - Should handle network errors gracefully and provide meaningful error messages -/// -/// ## Example Implementation -/// ```swift -/// struct MyFetcher: DataSourceFetcher { -/// func fetch() async throws -> [MyRecord] { -/// let url = URL(string: "https://api.example.com/data")! -/// let (data, _) = try await URLSession.shared.data(from: url) -/// let items = try JSONDecoder().decode([Item].self, from: data) -/// return items.map { MyRecord(from: $0) } -/// } -/// } -/// ``` -protocol DataSourceFetcher: Sendable { - /// The type of records this fetcher produces - associatedtype Record - - /// Fetch records from the external data source - /// - /// - Returns: Collection of records fetched from the source - /// - Throws: Errors related to network requests, parsing, or data validation - func fetch() async throws -> Record -} - -/// Common utilities for data source fetchers -enum DataSourceUtilities { - /// Fetch data from a URL with optional Last-Modified header tracking - /// - /// This helper combines data fetching with Last-Modified header extraction, - /// allowing fetchers to track when their source data was last updated. - /// - /// - Parameters: - /// - url: The URL to fetch from - /// - trackLastModified: Whether to make a HEAD request to get Last-Modified (default: true) - /// - Returns: Tuple of (data, lastModified date or nil) - /// - Throws: Errors from URLSession or network issues - static func fetchData( - from url: URL, - trackLastModified: Bool = true - ) async throws -> (Data, Date?) { - let lastModified = trackLastModified ? await HTTPHeaderHelpers.fetchLastModified(from: url) : nil - let (data, _) = try await URLSession.shared.data(from: url) - return (data, lastModified) - } - - /// Decode JSON data with helpful error logging - /// - /// - Parameters: - /// - type: The type to decode to - /// - data: The JSON data to decode - /// - source: Source name for error logging - /// - Returns: Decoded object - /// - Throws: DecodingError with context - static func decodeJSON( - _ type: T.Type, - from data: Data, - source: String - ) throws -> T { - do { - return try JSONDecoder().decode(type, from: data) - } catch { - BushelLogger.warning( - "Failed to decode \(T.self) from \(source): \(error)", - subsystem: BushelLogger.dataSource - ) - throw error - } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/DataSourcePipeline.swift b/Examples/Bushel/Sources/BushelImages/DataSources/DataSourcePipeline.swift deleted file mode 100644 index 6acad857..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/DataSourcePipeline.swift +++ /dev/null @@ -1,546 +0,0 @@ -import Foundation -internal import MistKit - -/// Orchestrates fetching data from all sources with deduplication and relationship resolution -struct DataSourcePipeline: Sendable { - // MARK: - Configuration - - struct Options: Sendable { - var includeRestoreImages: Bool = true - var includeXcodeVersions: Bool = true - var includeSwiftVersions: Bool = true - var includeBetaReleases: Bool = true - var includeAppleDB: Bool = true - var includeTheAppleWiki: Bool = true - var force: Bool = false - var specificSource: String? - } - - // MARK: - Dependencies - - let cloudKitService: BushelCloudKitService? - let configuration: FetchConfiguration - - // MARK: - Initialization - - init( - cloudKitService: BushelCloudKitService? = nil, - configuration: FetchConfiguration = FetchConfiguration.loadFromEnvironment() - ) { - self.cloudKitService = cloudKitService - self.configuration = configuration - } - - // MARK: - Results - - struct FetchResult: Sendable { - var restoreImages: [RestoreImageRecord] - var xcodeVersions: [XcodeVersionRecord] - var swiftVersions: [SwiftVersionRecord] - } - - // MARK: - Public API - - /// Fetch all data from configured sources - func fetch(options: Options = Options()) async throws -> FetchResult { - var restoreImages: [RestoreImageRecord] = [] - var xcodeVersions: [XcodeVersionRecord] = [] - var swiftVersions: [SwiftVersionRecord] = [] - - do { - restoreImages = try await fetchRestoreImages(options: options) - } catch { - print("⚠️ Restore images fetch failed: \(error)") - throw error - } - - do { - xcodeVersions = try await fetchXcodeVersions(options: options) - // Resolve XcodeVersion → RestoreImage references now that we have both datasets - xcodeVersions = resolveXcodeVersionReferences(xcodeVersions, restoreImages: restoreImages) - } catch { - print("⚠️ Xcode versions fetch failed: \(error)") - throw error - } - - do { - swiftVersions = try await fetchSwiftVersions(options: options) - } catch { - print("⚠️ Swift versions fetch failed: \(error)") - throw error - } - - return FetchResult( - restoreImages: restoreImages, - xcodeVersions: xcodeVersions, - swiftVersions: swiftVersions - ) - } - - // MARK: - Metadata Tracking - - /// Check if a source should be fetched based on throttling rules - private func shouldFetch( - source: String, - recordType: String, - force: Bool - ) async -> (shouldFetch: Bool, metadata: DataSourceMetadata?) { - // If force flag is set, always fetch - guard !force else { return (true, nil) } - - // If no CloudKit service, can't check metadata - fetch - guard let cloudKit = cloudKitService else { return (true, nil) } - - // Try to fetch metadata from CloudKit - do { - let metadata = try await cloudKit.queryDataSourceMetadata( - source: source, - recordType: recordType - ) - - // If no metadata exists, this is first fetch - allow it - guard let existingMetadata = metadata else { return (true, nil) } - - // Check configuration to see if enough time has passed - let shouldFetch = configuration.shouldFetch( - source: source, - lastFetchedAt: existingMetadata.lastFetchedAt, - force: force - ) - - return (shouldFetch, existingMetadata) - } catch { - // If metadata query fails, allow fetch but log warning - print(" ⚠️ Failed to query metadata for \(source): \(error)") - return (true, nil) - } - } - - /// Wrap a fetch operation with metadata tracking - private func fetchWithMetadata( - source: String, - recordType: String, - options: Options, - fetcher: () async throws -> [T] - ) async throws -> [T] { - // Check if we should skip this source based on --source flag - if let specificSource = options.specificSource, specificSource != source { - print(" ⏭️ Skipping \(source) (--source=\(specificSource))") - return [] - } - - // Check throttling - let (shouldFetch, existingMetadata) = await shouldFetch( - source: source, - recordType: recordType, - force: options.force - ) - - if !shouldFetch { - if let metadata = existingMetadata { - let timeSinceLastFetch = Date().timeIntervalSince(metadata.lastFetchedAt) - let minInterval = configuration.minimumInterval(for: source) ?? 0 - let timeRemaining = minInterval - timeSinceLastFetch - print(" ⏰ Skipping \(source) (last fetched \(Int(timeSinceLastFetch / 60))m ago, wait \(Int(timeRemaining / 60))m)") - } - return [] - } - - // Perform the fetch with timing - let startTime = Date() - var fetchError: Error? - var recordCount = 0 - - do { - let results = try await fetcher() - recordCount = results.count - - // Update metadata on success - if let cloudKit = cloudKitService { - let metadata = DataSourceMetadata( - sourceName: source, - recordTypeName: recordType, - lastFetchedAt: startTime, - sourceUpdatedAt: existingMetadata?.sourceUpdatedAt, - recordCount: recordCount, - fetchDurationSeconds: Date().timeIntervalSince(startTime), - lastError: nil - ) - - do { - try await cloudKit.sync([metadata]) - } catch { - print(" ⚠️ Failed to update metadata for \(source): \(error)") - } - } - - return results - } catch { - fetchError = error - - // Update metadata on error - if let cloudKit = cloudKitService { - let metadata = DataSourceMetadata( - sourceName: source, - recordTypeName: recordType, - lastFetchedAt: startTime, - sourceUpdatedAt: existingMetadata?.sourceUpdatedAt, - recordCount: 0, - fetchDurationSeconds: Date().timeIntervalSince(startTime), - lastError: error.localizedDescription - ) - - do { - try await cloudKit.sync([metadata]) - } catch { - print(" ⚠️ Failed to update metadata for \(source): \(error)") - } - } - - throw error - } - } - - // MARK: - Private Fetching Methods - - private func fetchRestoreImages(options: Options) async throws -> [RestoreImageRecord] { - guard options.includeRestoreImages else { - return [] - } - - var allImages: [RestoreImageRecord] = [] - - // Fetch from ipsw.me - do { - let ipswImages = try await fetchWithMetadata( - source: "ipsw.me", - recordType: "RestoreImage", - options: options - ) { - try await IPSWFetcher().fetch() - } - allImages.append(contentsOf: ipswImages) - if !ipswImages.isEmpty { - print(" ✓ ipsw.me: \(ipswImages.count) images") - } - } catch { - print(" ⚠️ ipsw.me failed: \(error)") - throw error - } - - // Fetch from MESU - do { - let mesuImages = try await fetchWithMetadata( - source: "mesu.apple.com", - recordType: "RestoreImage", - options: options - ) { - if let image = try await MESUFetcher().fetch() { - return [image] - } else { - return [] - } - } - allImages.append(contentsOf: mesuImages) - if !mesuImages.isEmpty { - print(" ✓ MESU: \(mesuImages.count) image") - } - } catch { - print(" ⚠️ MESU failed: \(error)") - throw error - } - - // Fetch from AppleDB - if options.includeAppleDB { - do { - let appleDBImages = try await fetchWithMetadata( - source: "appledb.dev", - recordType: "RestoreImage", - options: options - ) { - try await AppleDBFetcher().fetch() - } - allImages.append(contentsOf: appleDBImages) - if !appleDBImages.isEmpty { - print(" ✓ AppleDB: \(appleDBImages.count) images") - } - } catch { - print(" ⚠️ AppleDB failed: \(error)") - // Don't throw - continue with other sources - } - } - - // Fetch from Mr. Macintosh (betas) - if options.includeBetaReleases { - do { - let mrMacImages = try await fetchWithMetadata( - source: "mrmacintosh.com", - recordType: "RestoreImage", - options: options - ) { - try await MrMacintoshFetcher().fetch() - } - allImages.append(contentsOf: mrMacImages) - if !mrMacImages.isEmpty { - print(" ✓ Mr. Macintosh: \(mrMacImages.count) images") - } - } catch { - print(" ⚠️ Mr. Macintosh failed: \(error)") - throw error - } - } - - // Fetch from TheAppleWiki - if options.includeTheAppleWiki { - do { - let wikiImages = try await fetchWithMetadata( - source: "theapplewiki.com", - recordType: "RestoreImage", - options: options - ) { - try await TheAppleWikiFetcher().fetch() - } - allImages.append(contentsOf: wikiImages) - if !wikiImages.isEmpty { - print(" ✓ TheAppleWiki: \(wikiImages.count) images") - } - } catch { - print(" ⚠️ TheAppleWiki failed: \(error)") - throw error - } - } - - // Deduplicate by build number (keep first occurrence) - let preDedupeCount = allImages.count - let deduped = deduplicateRestoreImages(allImages) - print(" 📦 Deduplicated: \(preDedupeCount) → \(deduped.count) images") - return deduped - } - - private func fetchXcodeVersions(options: Options) async throws -> [XcodeVersionRecord] { - guard options.includeXcodeVersions else { - return [] - } - - let versions = try await fetchWithMetadata( - source: "xcodereleases.com", - recordType: "XcodeVersion", - options: options - ) { - try await XcodeReleasesFetcher().fetch() - } - - if !versions.isEmpty { - print(" ✓ xcodereleases.com: \(versions.count) versions") - } - - return deduplicateXcodeVersions(versions) - } - - private func fetchSwiftVersions(options: Options) async throws -> [SwiftVersionRecord] { - guard options.includeSwiftVersions else { - return [] - } - - let versions = try await fetchWithMetadata( - source: "swiftversion.net", - recordType: "SwiftVersion", - options: options - ) { - try await SwiftVersionFetcher().fetch() - } - - if !versions.isEmpty { - print(" ✓ swiftversion.net: \(versions.count) versions") - } - - return deduplicateSwiftVersions(versions) - } - - // MARK: - Deduplication - - /// Deduplicate restore images by build number, keeping the most complete record - private func deduplicateRestoreImages(_ images: [RestoreImageRecord]) -> [RestoreImageRecord] { - var uniqueImages: [String: RestoreImageRecord] = [:] - - for image in images { - let key = image.buildNumber - - if let existing = uniqueImages[key] { - // Keep the record with more complete data - uniqueImages[key] = mergeRestoreImages(existing, image) - } else { - uniqueImages[key] = image - } - } - - return Array(uniqueImages.values).sorted { $0.releaseDate > $1.releaseDate } - } - - /// Merge two restore image records, preferring non-empty values - /// - /// This method handles backfilling missing data from different sources: - /// - SHA-256 hashes from AppleDB fill in empty values from ipsw.me - /// - File sizes and SHA-1 hashes are similarly backfilled - /// - Signing status follows MESU authoritative rules - private func mergeRestoreImages( - _ first: RestoreImageRecord, - _ second: RestoreImageRecord - ) -> RestoreImageRecord { - var merged = first - - // Backfill missing hashes and file size from second record - if !second.sha256Hash.isEmpty && first.sha256Hash.isEmpty { - merged.sha256Hash = second.sha256Hash - } - if !second.sha1Hash.isEmpty && first.sha1Hash.isEmpty { - merged.sha1Hash = second.sha1Hash - } - if second.fileSize > 0 && first.fileSize == 0 { - merged.fileSize = second.fileSize - } - - // Merge isSigned with priority rules: - // 1. MESU is always authoritative (Apple's real-time signing status) - // 2. For non-MESU sources, prefer the most recently updated - // 3. If both have same update time (or both nil) and disagree, prefer false - - if first.source == "mesu.apple.com" && first.isSigned != nil { - merged.isSigned = first.isSigned // MESU first is authoritative - } else if second.source == "mesu.apple.com" && second.isSigned != nil { - merged.isSigned = second.isSigned // MESU second is authoritative - } else { - // Neither is MESU, compare update timestamps - let firstUpdated = first.sourceUpdatedAt - let secondUpdated = second.sourceUpdatedAt - - if let firstDate = firstUpdated, let secondDate = secondUpdated { - // Both have dates - use the more recent one - if secondDate > firstDate && second.isSigned != nil { - merged.isSigned = second.isSigned - } else if firstDate >= secondDate && first.isSigned != nil { - merged.isSigned = first.isSigned - } else if first.isSigned != nil { - merged.isSigned = first.isSigned - } else { - merged.isSigned = second.isSigned - } - } else if secondUpdated != nil && second.isSigned != nil { - // Second has date, first doesn't - prefer second - merged.isSigned = second.isSigned - } else if firstUpdated != nil && first.isSigned != nil { - // First has date, second doesn't - prefer first - merged.isSigned = first.isSigned - } else if first.isSigned != nil && second.isSigned != nil { - // Both have values but no dates - prefer false when they disagree - if first.isSigned == second.isSigned { - merged.isSigned = first.isSigned - } else { - merged.isSigned = false - } - } else if second.isSigned != nil { - merged.isSigned = second.isSigned - } else if first.isSigned != nil { - merged.isSigned = first.isSigned - } - } - - // Combine notes - if let secondNotes = second.notes, !secondNotes.isEmpty { - if let firstNotes = first.notes, !firstNotes.isEmpty { - merged.notes = "\(firstNotes); \(secondNotes)" - } else { - merged.notes = secondNotes - } - } - - return merged - } - - /// Resolve XcodeVersion → RestoreImage references by mapping version strings to record names - /// - /// Parses the temporary REQUIRES field in notes and matches it to RestoreImage versions - private func resolveXcodeVersionReferences( - _ versions: [XcodeVersionRecord], - restoreImages: [RestoreImageRecord] - ) -> [XcodeVersionRecord] { - // Build lookup table: version → RestoreImage recordName - var versionLookup: [String: String] = [:] - for image in restoreImages { - // Support multiple version formats: "14.2.1", "14.2", "14" - let version = image.version - versionLookup[version] = image.recordName - - // Also add short versions for matching (e.g., "14.2.1" → "14.2") - let components = version.split(separator: ".") - if components.count > 1 { - let shortVersion = components.prefix(2).joined(separator: ".") - versionLookup[shortVersion] = image.recordName - } - } - - return versions.map { version in - var resolved = version - - // Parse notes field to extract requires string - guard let notes = version.notes else { return resolved } - - let parts = notes.split(separator: "|") - var requiresString: String? - var notesURL: String? - - for part in parts { - if part.hasPrefix("REQUIRES:") { - requiresString = String(part.dropFirst("REQUIRES:".count)) - } else if part.hasPrefix("NOTES_URL:") { - notesURL = String(part.dropFirst("NOTES_URL:".count)) - } - } - - // Try to extract version number from requires (e.g., "macOS 14.2" → "14.2") - if let requires = requiresString { - // Match version patterns like "14.2", "14.2.1", etc. - let versionPattern = #/(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)/# - if let match = requires.firstMatch(of: versionPattern) { - let extractedVersion = String(match.1) - if let recordName = versionLookup[extractedVersion] { - resolved.minimumMacOS = recordName - } - } - } - - // Restore clean notes field - resolved.notes = notesURL - - return resolved - } - } - - /// Deduplicate Xcode versions by build number - private func deduplicateXcodeVersions(_ versions: [XcodeVersionRecord]) -> [XcodeVersionRecord] { - var uniqueVersions: [String: XcodeVersionRecord] = [:] - - for version in versions { - let key = version.buildNumber - if uniqueVersions[key] == nil { - uniqueVersions[key] = version - } - } - - return Array(uniqueVersions.values).sorted { $0.releaseDate > $1.releaseDate } - } - - /// Deduplicate Swift versions by version number - private func deduplicateSwiftVersions(_ versions: [SwiftVersionRecord]) -> [SwiftVersionRecord] { - var uniqueVersions: [String: SwiftVersionRecord] = [:] - - for version in versions { - let key = version.version - if uniqueVersions[key] == nil { - uniqueVersions[key] = version - } - } - - return Array(uniqueVersions.values).sorted { $0.releaseDate > $1.releaseDate } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/HTTPHeaderHelpers.swift b/Examples/Bushel/Sources/BushelImages/DataSources/HTTPHeaderHelpers.swift deleted file mode 100644 index d8e97410..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/HTTPHeaderHelpers.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -/// Utilities for fetching HTTP headers from data sources -enum HTTPHeaderHelpers { - /// Fetches the Last-Modified header from a URL - /// - Parameter url: The URL to fetch the header from - /// - Returns: The Last-Modified date, or nil if not available - static func fetchLastModified(from url: URL) async -> Date? { - do { - var request = URLRequest(url: url) - request.httpMethod = "HEAD" - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - let lastModifiedString = httpResponse.value(forHTTPHeaderField: "Last-Modified") else { - return nil - } - - return parseLastModifiedDate(from: lastModifiedString) - } catch { - BushelLogger.warning("Failed to fetch Last-Modified header from \(url): \(error)", subsystem: BushelLogger.dataSource) - return nil - } - } - - /// Parses a Last-Modified header value in RFC 2822 format - /// - Parameter dateString: The date string from the header - /// - Returns: The parsed date, or nil if parsing fails - private static func parseLastModifiedDate(from dateString: String) -> Date? { - let formatter = DateFormatter() - formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - return formatter.date(from: dateString) - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/IPSWFetcher.swift b/Examples/Bushel/Sources/BushelImages/DataSources/IPSWFetcher.swift deleted file mode 100644 index 81c1f01b..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/IPSWFetcher.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import IPSWDownloads -import OpenAPIURLSession -import OSVer - -/// Fetcher for macOS restore images using the IPSWDownloads package -struct IPSWFetcher: DataSourceFetcher, Sendable { - typealias Record = [RestoreImageRecord] - /// Fetch all VirtualMac2,1 restore images from ipsw.me - func fetch() async throws -> [RestoreImageRecord] { - // Fetch Last-Modified header to know when ipsw.me data was updated - let ipswURL = URL(string: "https://api.ipsw.me/v4/device/VirtualMac2,1?type=ipsw")! - let lastModified = await HTTPHeaderHelpers.fetchLastModified(from: ipswURL) - - // Create IPSWDownloads client with URLSession transport - let client = IPSWDownloads( - transport: URLSessionTransport() - ) - - // Fetch device firmware data for VirtualMac2,1 (macOS virtual machines) - let device = try await client.device( - withIdentifier: "VirtualMac2,1", - type: .ipsw - ) - - return device.firmwares.map { firmware in - RestoreImageRecord( - version: firmware.version.description, // OSVer -> String - buildNumber: firmware.buildid, - releaseDate: firmware.releasedate, - downloadURL: firmware.url.absoluteString, - fileSize: firmware.filesize, - sha256Hash: "", // Not provided by ipsw.me; backfilled from AppleDB during merge - sha1Hash: firmware.sha1sum?.hexString ?? "", - isSigned: firmware.signed, - isPrerelease: false, // ipsw.me doesn't include beta releases - source: "ipsw.me", - notes: nil, - sourceUpdatedAt: lastModified // When ipsw.me last updated their database - ) - } - } -} - -// MARK: - Data Extension - -private extension Data { - /// Convert Data to hexadecimal string - var hexString: String { - map { String(format: "%02x", $0) }.joined() - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/MESUFetcher.swift b/Examples/Bushel/Sources/BushelImages/DataSources/MESUFetcher.swift deleted file mode 100644 index e421a189..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/MESUFetcher.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -/// Fetcher for Apple MESU (Mobile Equipment Software Update) manifest -/// Used for freshness detection of the latest signed restore image -struct MESUFetcher: DataSourceFetcher, Sendable { - typealias Record = RestoreImageRecord? - // MARK: - Internal Models - - fileprivate struct RestoreInfo: Codable { - let BuildVersion: String - let ProductVersion: String - let FirmwareURL: String - let FirmwareSHA1: String? - } - - // MARK: - Public API - - /// Fetch the latest signed restore image from Apple's MESU service - func fetch() async throws -> RestoreImageRecord? { - let urlString = "https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml" - guard let url = URL(string: urlString) else { - throw FetchError.invalidURL - } - - // Fetch Last-Modified header to know when MESU was last updated - let lastModified = await HTTPHeaderHelpers.fetchLastModified(from: url) - - let (data, _) = try await URLSession.shared.data(from: url) - - // Parse as property list (plist) - guard let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { - throw FetchError.parsingFailed - } - - // Navigate to the firmware data - // Structure: MobileDeviceSoftwareVersionsByVersion -> "1" -> MobileDeviceSoftwareVersions -> VirtualMac2,1 -> BuildVersion -> Restore - guard let versionsByVersion = plist["MobileDeviceSoftwareVersionsByVersion"] as? [String: Any], - let version1 = versionsByVersion["1"] as? [String: Any], - let softwareVersions = version1["MobileDeviceSoftwareVersions"] as? [String: Any], - let virtualMac = softwareVersions["VirtualMac2,1"] as? [String: Any] else { - return nil - } - - // Find the first available build (should be the latest signed) - for (buildVersion, buildInfo) in virtualMac { - guard let buildInfo = buildInfo as? [String: Any], - let restoreDict = buildInfo["Restore"] as? [String: Any], - let productVersion = restoreDict["ProductVersion"] as? String, - let firmwareURL = restoreDict["FirmwareURL"] as? String else { - continue - } - - let firmwareSHA1 = restoreDict["FirmwareSHA1"] as? String ?? "" - - // Return the first restore image found (typically the latest) - return RestoreImageRecord( - version: productVersion, - buildNumber: buildVersion, - releaseDate: Date(), // MESU doesn't provide release date, use current date - downloadURL: firmwareURL, - fileSize: 0, // Not provided by MESU - sha256Hash: "", // MESU only provides SHA1 - sha1Hash: firmwareSHA1, - isSigned: true, // MESU only lists currently signed images - isPrerelease: false, // MESU typically only has final releases - source: "mesu.apple.com", - notes: "Latest signed release from Apple MESU", - sourceUpdatedAt: lastModified // When Apple last updated MESU manifest - ) - } - - // No restore images found in the plist - return nil - } - - // MARK: - Error Types - - enum FetchError: Error { - case invalidURL - case parsingFailed - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/MrMacintoshFetcher.swift b/Examples/Bushel/Sources/BushelImages/DataSources/MrMacintoshFetcher.swift deleted file mode 100644 index 275079fe..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/MrMacintoshFetcher.swift +++ /dev/null @@ -1,176 +0,0 @@ -import Foundation -import SwiftSoup - -/// Fetcher for macOS beta/RC restore images from Mr. Macintosh database -internal struct MrMacintoshFetcher: DataSourceFetcher, Sendable { - internal typealias Record = [RestoreImageRecord] - // MARK: - Public API - - /// Fetch beta and RC restore images from Mr. Macintosh - internal func fetch() async throws -> [RestoreImageRecord] { - let urlString = "https://mrmacintosh.com/apple-silicon-m1-full-macos-restore-ipsw-firmware-files-database/" - guard let url = URL(string: urlString) else { - throw FetchError.invalidURL - } - - let (data, _) = try await URLSession.shared.data(from: url) - guard let html = String(data: data, encoding: .utf8) else { - throw FetchError.invalidEncoding - } - - let doc = try SwiftSoup.parse(html) - - // Extract the page update date from UPDATED: MM/DD/YY - var pageUpdatedAt: Date? - if let strongElements = try? doc.select("strong"), - let updateElement = strongElements.first(where: { element in - (try? element.text().uppercased().starts(with: "UPDATED:")) == true - }), - let updateText = try? updateElement.text(), - let dateString = updateText.split(separator: ":").last?.trimmingCharacters(in: .whitespaces) { - pageUpdatedAt = parseDateMMDDYY(from: String(dateString)) - if let date = pageUpdatedAt { - BushelLogger.verbose("Mr. Macintosh page last updated: \(date)", subsystem: BushelLogger.dataSource) - } - } - - // Find all table rows - let rows = try doc.select("table tr") - - let records = rows.compactMap { row in - parseTableRow(row, pageUpdatedAt: pageUpdatedAt) - } - - return records - } - - // MARK: - Helpers - - /// Parse a table row into a RestoreImageRecord - private func parseTableRow(_ row: SwiftSoup.Element, pageUpdatedAt: Date?) -> RestoreImageRecord? { - do { - let cells = try row.select("td") - guard cells.count >= 3 else { return nil } - - // Expected columns: Download Link | Version | Date | [Optional: Signed Status] - // Extract filename and URL from first cell - guard let linkElement = try cells[0].select("a").first(), - let downloadURL = try? linkElement.attr("href"), - !downloadURL.isEmpty else { - return nil - } - - let filename = try linkElement.text() - - // Parse filename like "UniversalMac_26.1_25B78_Restore.ipsw" - // Extract version and build from filename - guard filename.contains("UniversalMac") else { return nil } - - let components = filename.replacingOccurrences(of: ".ipsw", with: "") - .components(separatedBy: "_") - guard components.count >= 3 else { return nil } - - let version = components[1] - let buildNumber = components[2] - - // Get version from second cell (more reliable) - let versionFromCell = try cells[1].text() - - // Get date from third cell - let dateStr = try cells[2].text() - let releaseDate = parseDate(from: dateStr) ?? Date() - - // Check if signed (4th column if present) - let isSigned: Bool? = cells.count >= 4 ? try cells[3].text().uppercased().contains("YES") : nil - - // Determine if it's a beta/RC release from filename or version - let isPrerelease = filename.lowercased().contains("beta") || - filename.lowercased().contains("rc") || - versionFromCell.lowercased().contains("beta") || - versionFromCell.lowercased().contains("rc") - - return RestoreImageRecord( - version: version, - buildNumber: buildNumber, - releaseDate: releaseDate, - downloadURL: downloadURL, - fileSize: 0, // Not provided - sha256Hash: "", // Not provided - sha1Hash: "", // Not provided - isSigned: isSigned, - isPrerelease: isPrerelease, - source: "mrmacintosh.com", - notes: nil, - sourceUpdatedAt: pageUpdatedAt // Date when Mr. Macintosh last updated the page - ) - } catch { - BushelLogger.verbose("Failed to parse table row: \(error)", subsystem: BushelLogger.dataSource) - return nil - } - } - - /// Parse date from Mr. Macintosh format (MM/DD/YY or M/D or M/DD) - private func parseDate(from string: String) -> Date? { - let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) - - // Try formats with year first - let formattersWithYear = [ - makeDateFormatter(format: "M/d/yy"), - makeDateFormatter(format: "MM/dd/yy"), - makeDateFormatter(format: "M/d/yyyy"), - makeDateFormatter(format: "MM/dd/yyyy") - ] - - for formatter in formattersWithYear { - if let date = formatter.date(from: trimmed) { - return date - } - } - - // If no year, assume current or previous year - let formattersNoYear = [ - makeDateFormatter(format: "M/d"), - makeDateFormatter(format: "MM/dd") - ] - - for formatter in formattersNoYear { - if let date = formatter.date(from: trimmed) { - // Add current year - let calendar = Calendar.current - let currentYear = calendar.component(.year, from: Date()) - var components = calendar.dateComponents([.month, .day], from: date) - components.year = currentYear - - // If date is in the future, use previous year - if let dateWithYear = calendar.date(from: components), dateWithYear > Date() { - components.year = currentYear - 1 - } - - return calendar.date(from: components) - } - } - - return nil - } - - /// Parse date from page update format (MM/DD/YY) - private func parseDateMMDDYY(from string: String) -> Date? { - let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) - let formatter = makeDateFormatter(format: "MM/dd/yy") - return formatter.date(from: trimmed) - } - - private func makeDateFormatter(format: String) -> DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = format - formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter - } - - // MARK: - Error Types - - enum FetchError: Error { - case invalidURL - case invalidEncoding - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/SwiftVersionFetcher.swift b/Examples/Bushel/Sources/BushelImages/DataSources/SwiftVersionFetcher.swift deleted file mode 100644 index 5481c0fc..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/SwiftVersionFetcher.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -import SwiftSoup - -/// Fetcher for Swift compiler versions from swiftversion.net -struct SwiftVersionFetcher: DataSourceFetcher, Sendable { - typealias Record = [SwiftVersionRecord] - // MARK: - Internal Models - - private struct SwiftVersionEntry { - let date: Date - let swiftVersion: String - let xcodeVersion: String - } - - // MARK: - Public API - - /// Fetch all Swift versions from swiftversion.net - func fetch() async throws -> [SwiftVersionRecord] { - let url = URL(string: "https://swiftversion.net")! - let (data, _) = try await URLSession.shared.data(from: url) - guard let html = String(data: data, encoding: .utf8) else { - throw FetchError.invalidEncoding - } - - let doc = try SwiftSoup.parse(html) - let rows = try doc.select("tbody tr.table-entry") - - var entries: [SwiftVersionEntry] = [] - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "dd MMM yy" - - for row in rows { - let cells = try row.select("td") - guard cells.count == 3 else { continue } - - let dateStr = try cells[0].text() - let swiftVer = try cells[1].text() - let xcodeVer = try cells[2].text() - - guard let date = dateFormatter.date(from: dateStr) else { - print("Warning: Could not parse date: \(dateStr)") - continue - } - - entries.append(SwiftVersionEntry( - date: date, - swiftVersion: swiftVer, - xcodeVersion: xcodeVer - )) - } - - return entries.map { entry in - SwiftVersionRecord( - version: entry.swiftVersion, - releaseDate: entry.date, - downloadURL: "https://swift.org/download/", // Generic download page - isPrerelease: entry.swiftVersion.contains("beta") || - entry.swiftVersion.contains("snapshot"), - notes: "Bundled with Xcode \(entry.xcodeVersion)" - ) - } - } - - // MARK: - Error Types - - enum FetchError: Error { - case invalidEncoding - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/IPSWParser.swift b/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/IPSWParser.swift deleted file mode 100644 index dd2b86fb..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/IPSWParser.swift +++ /dev/null @@ -1,191 +0,0 @@ -import Foundation - -// MARK: - Errors - -enum TheAppleWikiError: LocalizedError { - case invalidURL(String) - case networkError(underlying: Error) - case parsingError(String) - case noDataFound - - var errorDescription: String? { - switch self { - case .invalidURL(let url): - return "Invalid URL: \(url)" - case .networkError(let error): - return "Network error: \(error.localizedDescription)" - case .parsingError(let details): - return "Parsing error: \(details)" - case .noDataFound: - return "No IPSW data found" - } - } -} - -// MARK: - Parser - -/// Fetches macOS IPSW metadata from TheAppleWiki.com -@available(macOS 12.0, *) -struct IPSWParser: Sendable { - private let baseURL = "https://theapplewiki.com" - private let apiEndpoint = "/api.php" - - /// Fetch all available IPSW versions for macOS 12+ - /// - Parameter deviceFilter: Optional device identifier to filter by (e.g., "VirtualMac2,1") - /// - Returns: Array of IPSW versions matching the filter - func fetchAllIPSWVersions(deviceFilter: String? = nil) async throws -> [IPSWVersion] { - // Get list of Mac firmware pages - let pagesURL = try buildPagesURL() - let pagesData = try await fetchData(from: pagesURL) - let pagesResponse = try JSONDecoder().decode(ParseResponse.self, from: pagesData) - - var allVersions: [IPSWVersion] = [] - - // Extract firmware page links from content - let content = pagesResponse.parse.text.content - let versionPages = try extractVersionPages(from: content) - - // Fetch versions from each page - for pageTitle in versionPages { - let pageURL = try buildPageURL(for: pageTitle) - do { - let versions = try await parseIPSWPage(url: pageURL, deviceFilter: deviceFilter) - allVersions.append(contentsOf: versions) - } catch { - // Continue on page parse errors - some pages may be empty or malformed - continue - } - } - - guard !allVersions.isEmpty else { - throw TheAppleWikiError.noDataFound - } - - return allVersions - } - - // MARK: - Private Methods - - private func buildPagesURL() throws -> URL { - guard let url = URL(string: baseURL + apiEndpoint + "?action=parse&page=Firmware/Mac&format=json") else { - throw TheAppleWikiError.invalidURL("Firmware/Mac") - } - return url - } - - private func buildPageURL(for pageTitle: String) throws -> URL { - guard let encoded = pageTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let url = URL(string: baseURL + apiEndpoint + "?action=parse&page=\(encoded)&format=json") else { - throw TheAppleWikiError.invalidURL(pageTitle) - } - return url - } - - private func fetchData(from url: URL) async throws -> Data { - do { - let (data, _) = try await URLSession.shared.data(from: url) - return data - } catch { - throw TheAppleWikiError.networkError(underlying: error) - } - } - - private func extractVersionPages(from content: String) throws -> [String] { - let pattern = #"Firmware/Mac/(\d+)\.x"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { - throw TheAppleWikiError.parsingError("Invalid regex pattern") - } - - let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) - - let versionPages = matches.compactMap { match -> String? in - guard let range = Range(match.range(at: 1), in: content), - let version = Double(content[range]), - version >= 12 else { - return nil - } - return "Firmware/Mac/\(Int(version)).x" - } - - return versionPages - } - - private func parseIPSWPage(url: URL, deviceFilter: String?) async throws -> [IPSWVersion] { - let data = try await fetchData(from: url) - let response = try JSONDecoder().decode(ParseResponse.self, from: data) - - var versions: [IPSWVersion] = [] - - // Split content into rows (basic HTML parsing) - let rows = response.parse.text.content.components(separatedBy: " String? in - // Extract text between td tags, removing HTML - guard let endIndex = cell.range(of: "")?.lowerBound else { return nil } - let content = cell[..]+>", with: "", options: .regularExpression) - } - - guard cells.count >= 6 else { continue } - - let version = cells[0] - let buildNumber = cells[1] - let deviceModel = cells[2] - let fileName = cells[3] - - // Skip if filename doesn't end with ipsw - guard fileName.lowercased().hasSuffix("ipsw") else { continue } - - // Apply device filter if specified - if let filter = deviceFilter, !deviceModel.contains(filter) { - continue - } - - let fileSize = cells[4] - let sha1 = cells[5] - - let releaseDate: Date? = cells.count > 6 ? parseDate(cells[6]) : nil - let url: URL? = parseURL(from: cells[3]) - - versions.append(IPSWVersion( - version: version, - buildNumber: buildNumber, - deviceModel: deviceModel, - fileName: fileName, - fileSize: fileSize, - sha1: sha1, - releaseDate: releaseDate, - url: url - )) - } - - return versions - } - - private func parseDate(_ str: String) -> Date? { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter.date(from: str) - } - - private func parseURL(from text: String) -> URL? { - // Extract URL from possible HTML link in text - let pattern = #"href="([^"]+)"# - guard let match = text.range(of: pattern, options: .regularExpression) else { - return nil - } - - let urlString = String(text[match]) - .replacingOccurrences(of: "href=\"", with: "") - .replacingOccurrences(of: "\"", with: "") - - return URL(string: urlString) - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/Models/IPSWVersion.swift b/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/Models/IPSWVersion.swift deleted file mode 100644 index 742065aa..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/Models/IPSWVersion.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -/// IPSW metadata from TheAppleWiki -struct IPSWVersion: Codable, Sendable { - let version: String - let buildNumber: String - let deviceModel: String - let fileName: String - let fileSize: String - let sha1: String - let releaseDate: Date? - let url: URL? - - // MARK: - Computed Properties - - /// Parse file size string to Int for CloudKit - /// Examples: "10.2 GB" -> bytes, "1.5 MB" -> bytes - var fileSizeInBytes: Int? { - let components = fileSize.components(separatedBy: " ") - guard components.count == 2, - let size = Double(components[0]) else { - return nil - } - - let unit = components[1].uppercased() - let multiplier: Double = switch unit { - case "GB": 1_000_000_000 - case "MB": 1_000_000 - case "KB": 1_000 - case "BYTES", "B": 1 - default: 0 - } - - guard multiplier > 0 else { return nil } - return Int(size * multiplier) - } - - /// Detect if this is a VirtualMac device - var isVirtualMac: Bool { - deviceModel.contains("VirtualMac") - } - - /// Detect if this is a prerelease version (beta, RC, etc.) - var isPrerelease: Bool { - let lowercased = version.lowercased() - return lowercased.contains("beta") - || lowercased.contains("rc") - || lowercased.contains("gm seed") - || lowercased.contains("developer preview") - } - - /// Validate that all required fields are present - var isValid: Bool { - !version.isEmpty - && !buildNumber.isEmpty - && !deviceModel.isEmpty - && !fileName.isEmpty - && !sha1.isEmpty - && url != nil - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/Models/WikiAPITypes.swift b/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/Models/WikiAPITypes.swift deleted file mode 100644 index 43d07d5f..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/Models/WikiAPITypes.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -// MARK: - TheAppleWiki API Response Types - -/// Root response from TheAppleWiki parse API -struct ParseResponse: Codable, Sendable { - let parse: ParseContent -} - -/// Parse content container -struct ParseContent: Codable, Sendable { - let title: String - let text: TextContent -} - -/// Text content with HTML -struct TextContent: Codable, Sendable { - let content: String - - enum CodingKeys: String, CodingKey { - case content = "*" - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift b/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift deleted file mode 100644 index f860512d..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -/// Fetcher for macOS restore images using TheAppleWiki.com -@available(*, deprecated, message: "Use AppleDBFetcher instead for more reliable and up-to-date data") -internal struct TheAppleWikiFetcher: DataSourceFetcher, Sendable { - internal typealias Record = [RestoreImageRecord] - /// Fetch all macOS restore images from TheAppleWiki - internal func fetch() async throws -> [RestoreImageRecord] { - // Fetch Last-Modified header from TheAppleWiki API - let apiURL = URL(string: "https://theapplewiki.com/api.php?action=parse&page=Firmware/Mac&format=json")! - let lastModified = await HTTPHeaderHelpers.fetchLastModified(from: apiURL) - - let parser = IPSWParser() - - // Fetch all versions without device filtering (UniversalMac images work for all devices) - let versions = try await parser.fetchAllIPSWVersions(deviceFilter: nil) - - // Map to RestoreImageRecord, filtering out only invalid entries - // Deduplication happens later in DataSourcePipeline - return versions - .filter { $0.isValid } - .compactMap { version -> RestoreImageRecord? in - // Skip if we can't get essential data - guard let downloadURL = version.url?.absoluteString, - let fileSize = version.fileSizeInBytes else { - return nil - } - - // Use current date as fallback if release date is missing - let releaseDate = version.releaseDate ?? Date() - - return RestoreImageRecord( - version: version.version, - buildNumber: version.buildNumber, - releaseDate: releaseDate, - downloadURL: downloadURL, - fileSize: fileSize, - sha256Hash: "", // Not available from TheAppleWiki - sha1Hash: version.sha1, - isSigned: nil, // Unknown - will be merged from other sources - isPrerelease: version.isPrerelease, - source: "theapplewiki.com", - notes: "Device: \(version.deviceModel)", - sourceUpdatedAt: lastModified // When TheAppleWiki API was last updated - ) - } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/DataSources/XcodeReleasesFetcher.swift b/Examples/Bushel/Sources/BushelImages/DataSources/XcodeReleasesFetcher.swift deleted file mode 100644 index af4ac7f0..00000000 --- a/Examples/Bushel/Sources/BushelImages/DataSources/XcodeReleasesFetcher.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -/// Fetcher for Xcode releases from xcodereleases.com JSON API -struct XcodeReleasesFetcher: DataSourceFetcher, Sendable { - typealias Record = [XcodeVersionRecord] - // MARK: - API Models - - private struct XcodeRelease: Codable { - let checksums: Checksums? - let compilers: Compilers? - let date: ReleaseDate - let links: Links? - let name: String - let requires: String - let sdks: SDKs? - let version: Version - - struct Checksums: Codable { - let sha1: String - } - - struct Compilers: Codable { - let clang: [Compiler]? - let swift: [Compiler]? - } - - struct Compiler: Codable { - let build: String? - let number: String? - let release: Release? - } - - struct Release: Codable { - let release: Bool? - let beta: Int? - let rc: Int? - - var isPrerelease: Bool { - beta != nil || rc != nil - } - } - - struct ReleaseDate: Codable { - let day: Int - let month: Int - let year: Int - - var toDate: Date { - let components = DateComponents(year: year, month: month, day: day) - return Calendar.current.date(from: components) ?? Date() - } - } - - struct Links: Codable { - let download: Download? - let notes: Notes? - - struct Download: Codable { - let url: String - } - - struct Notes: Codable { - let url: String - } - } - - struct SDKs: Codable { - let iOS: [SDK]? - let macOS: [SDK]? - let tvOS: [SDK]? - let visionOS: [SDK]? - let watchOS: [SDK]? - - struct SDK: Codable { - let build: String? - let number: String? - let release: Release? - } - } - - struct Version: Codable { - let build: String - let number: String - let release: Release - } - } - - // MARK: - Public API - - /// Fetch all Xcode releases from xcodereleases.com - func fetch() async throws -> [XcodeVersionRecord] { - let url = URL(string: "https://xcodereleases.com/data.json")! - let (data, _) = try await URLSession.shared.data(from: url) - let releases = try JSONDecoder().decode([XcodeRelease].self, from: data) - - return releases.map { release in - // Build SDK versions JSON (if SDK info is available) - var sdkDict: [String: String] = [:] - if let sdks = release.sdks { - if let ios = sdks.iOS?.first, let number = ios.number { sdkDict["iOS"] = number } - if let macos = sdks.macOS?.first, let number = macos.number { sdkDict["macOS"] = number } - if let tvos = sdks.tvOS?.first, let number = tvos.number { sdkDict["tvOS"] = number } - if let visionos = sdks.visionOS?.first, let number = visionos.number { sdkDict["visionOS"] = number } - if let watchos = sdks.watchOS?.first, let number = watchos.number { sdkDict["watchOS"] = number } - } - - // Encode SDK dictionary to JSON string with proper error handling - let sdkString: String? = { - do { - let data = try JSONEncoder().encode(sdkDict) - return String(data: data, encoding: .utf8) - } catch { - BushelLogger.warning( - "Failed to encode SDK versions for \(release.name): \(error)", - subsystem: BushelLogger.dataSource - ) - return nil - } - }() - - // Extract Swift version (if compilers info is available) - let swiftVersion = release.compilers?.swift?.first?.number - - // Store requires string temporarily for later resolution - // Format: "REQUIRES:|NOTES_URL:" - var notesField = "REQUIRES:\(release.requires)" - if let notesURL = release.links?.notes?.url { - notesField += "|NOTES_URL:\(notesURL)" - } - - return XcodeVersionRecord( - version: release.version.number, - buildNumber: release.version.build, - releaseDate: release.date.toDate, - downloadURL: release.links?.download?.url, - fileSize: nil, // Not provided by API - isPrerelease: release.version.release.isPrerelease, - minimumMacOS: nil, // Will be resolved in DataSourcePipeline - includedSwiftVersion: swiftVersion.map { "SwiftVersion-\($0)" }, - sdkVersions: sdkString, - notes: notesField - ) - } - } - -} diff --git a/Examples/Bushel/Sources/BushelImages/Logger.swift b/Examples/Bushel/Sources/BushelImages/Logger.swift deleted file mode 100644 index 164e8748..00000000 --- a/Examples/Bushel/Sources/BushelImages/Logger.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation -import Logging - -/// Centralized logging infrastructure for Bushel demo -/// -/// This demonstrates best practices for logging in CloudKit applications: -/// - Subsystem-based organization for filtering -/// - Educational logging that teaches CloudKit concepts -/// - Verbose mode for debugging and learning -/// -/// **Tutorial Note**: Use `--verbose` flag to see detailed CloudKit operations -enum BushelLogger { - // MARK: - Subsystems - - /// Logger for CloudKit operations (sync, queries, batch uploads) - static let cloudKit = Logger(label: "com.brightdigit.Bushel.cloudkit") - - /// Logger for external data source fetching (ipsw.me, TheAppleWiki, etc.) - static let dataSource = Logger(label: "com.brightdigit.Bushel.datasource") - - /// Logger for sync engine orchestration - static let sync = Logger(label: "com.brightdigit.Bushel.sync") - - // MARK: - Verbose Mode State - - /// Global verbose mode flag - set by command-line arguments - /// - /// Note: This is marked with `nonisolated(unsafe)` because it's set once at startup - /// before any concurrent access and then only read. This pattern is safe for CLI tools. - nonisolated(unsafe) static var isVerbose = false - - // MARK: - Logging Helpers - - /// Log informational message (always shown) - static func info(_ message: String, subsystem: Logger) { - print(message) - subsystem.info("\(message)") - } - - /// Log verbose message (only shown when --verbose is enabled) - static func verbose(_ message: String, subsystem: Logger) { - guard isVerbose else { return } - print(" 🔍 \(message)") - subsystem.debug("\(message)") - } - - /// Log educational explanation (shown in verbose mode) - /// - /// Use this to explain CloudKit concepts and MistKit usage patterns - static func explain(_ message: String, subsystem: Logger) { - guard isVerbose else { return } - print(" 💡 \(message)") - subsystem.debug("EXPLANATION: \(message)") - } - - /// Log warning message (always shown) - static func warning(_ message: String, subsystem: Logger) { - print(" ⚠️ \(message)") - subsystem.warning("\(message)") - } - - /// Log error message (always shown) - static func error(_ message: String, subsystem: Logger) { - print(" ❌ \(message)") - subsystem.error("\(message)") - } - - /// Log success message (always shown) - static func success(_ message: String, subsystem: Logger) { - print(" ✓ \(message)") - subsystem.info("SUCCESS: \(message)") - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Models/DataSourceMetadata.swift b/Examples/Bushel/Sources/BushelImages/Models/DataSourceMetadata.swift deleted file mode 100644 index ad1c1049..00000000 --- a/Examples/Bushel/Sources/BushelImages/Models/DataSourceMetadata.swift +++ /dev/null @@ -1,122 +0,0 @@ -// DataSourceMetadata.swift -// Created by Claude Code - -public import Foundation -public import MistKit - -/// Metadata about when a data source was last fetched and updated -public struct DataSourceMetadata: Codable, Sendable { - // MARK: Lifecycle - - public init( - sourceName: String, - recordTypeName: String, - lastFetchedAt: Date, - sourceUpdatedAt: Date? = nil, - recordCount: Int = 0, - fetchDurationSeconds: Double = 0, - lastError: String? = nil - ) { - self.sourceName = sourceName - self.recordTypeName = recordTypeName - self.lastFetchedAt = lastFetchedAt - self.sourceUpdatedAt = sourceUpdatedAt - self.recordCount = recordCount - self.fetchDurationSeconds = fetchDurationSeconds - self.lastError = lastError - } - - // MARK: Public - - /// The name of the data source (e.g., "appledb.dev", "ipsw.me") - public let sourceName: String - - /// The type of records this source provides (e.g., "RestoreImage", "XcodeVersion") - public let recordTypeName: String - - /// When we last fetched data from this source - public let lastFetchedAt: Date - - /// When the source last updated its data (from HTTP Last-Modified or API metadata) - public let sourceUpdatedAt: Date? - - /// Number of records retrieved from this source - public let recordCount: Int - - /// How long the fetch operation took in seconds - public let fetchDurationSeconds: Double - - /// Last error message if the fetch failed - public let lastError: String? - - /// CloudKit record name for this metadata entry - public var recordName: String { - "metadata-\(sourceName)-\(recordTypeName)" - } -} - -// MARK: - CloudKitRecord Conformance - -extension DataSourceMetadata: CloudKitRecord { - public static var cloudKitRecordType: String { "DataSourceMetadata" } - - public func toCloudKitFields() -> [String: FieldValue] { - var fields: [String: FieldValue] = [ - "sourceName": .string(sourceName), - "recordTypeName": .string(recordTypeName), - "lastFetchedAt": .date(lastFetchedAt), - "recordCount": .int64(recordCount), - "fetchDurationSeconds": .double(fetchDurationSeconds) - ] - - // Optional fields - if let sourceUpdatedAt { - fields["sourceUpdatedAt"] = .date(sourceUpdatedAt) - } - - if let lastError { - fields["lastError"] = .string(lastError) - } - - return fields - } - - public static func from(recordInfo: RecordInfo) -> Self? { - guard let sourceName = recordInfo.fields["sourceName"]?.stringValue, - let recordTypeName = recordInfo.fields["recordTypeName"]?.stringValue, - let lastFetchedAt = recordInfo.fields["lastFetchedAt"]?.dateValue - else { - return nil - } - - return DataSourceMetadata( - sourceName: sourceName, - recordTypeName: recordTypeName, - lastFetchedAt: lastFetchedAt, - sourceUpdatedAt: recordInfo.fields["sourceUpdatedAt"]?.dateValue, - recordCount: recordInfo.fields["recordCount"]?.intValue ?? 0, - fetchDurationSeconds: recordInfo.fields["fetchDurationSeconds"]?.doubleValue ?? 0, - lastError: recordInfo.fields["lastError"]?.stringValue - ) - } - - public static func formatForDisplay(_ recordInfo: RecordInfo) -> String { - let sourceName = recordInfo.fields["sourceName"]?.stringValue ?? "Unknown" - let recordTypeName = recordInfo.fields["recordTypeName"]?.stringValue ?? "Unknown" - let lastFetchedAt = recordInfo.fields["lastFetchedAt"]?.dateValue - let recordCount = recordInfo.fields["recordCount"]?.intValue ?? 0 - - let dateStr = lastFetchedAt.map { formatDate($0) } ?? "Unknown" - - var output = "\n \(sourceName) → \(recordTypeName)\n" - output += " Last fetched: \(dateStr) | Records: \(recordCount)" - return output - } - - private static func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: date) - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Models/RestoreImageRecord.swift b/Examples/Bushel/Sources/BushelImages/Models/RestoreImageRecord.swift deleted file mode 100644 index 145e6b30..00000000 --- a/Examples/Bushel/Sources/BushelImages/Models/RestoreImageRecord.swift +++ /dev/null @@ -1,136 +0,0 @@ -import Foundation -import MistKit - -/// Represents a macOS IPSW restore image for Apple Virtualization framework -struct RestoreImageRecord: Codable, Sendable { - /// macOS version (e.g., "14.2.1", "15.0 Beta 3") - var version: String - - /// Build identifier (e.g., "23C71", "24A5264n") - var buildNumber: String - - /// Official release date - var releaseDate: Date - - /// Direct IPSW download link - var downloadURL: String - - /// File size in bytes - var fileSize: Int - - /// SHA-256 checksum for integrity verification - var sha256Hash: String - - /// SHA-1 hash (from MESU/ipsw.me for compatibility) - var sha1Hash: String - - /// Whether Apple still signs this restore image (nil if unknown) - var isSigned: Bool? - - /// Beta/RC release indicator - var isPrerelease: Bool - - /// Data source: "ipsw.me", "mrmacintosh.com", "mesu.apple.com" - var source: String - - /// Additional metadata or release notes - var notes: String? - - /// When the source last updated this record (nil if unknown) - var sourceUpdatedAt: Date? - - /// CloudKit record name based on build number (e.g., "RestoreImage-23C71") - var recordName: String { - "RestoreImage-\(buildNumber)" - } -} - -// MARK: - CloudKitRecord Conformance - -extension RestoreImageRecord: CloudKitRecord { - static var cloudKitRecordType: String { "RestoreImage" } - - func toCloudKitFields() -> [String: FieldValue] { - var fields: [String: FieldValue] = [ - "version": .string(version), - "buildNumber": .string(buildNumber), - "releaseDate": .date(releaseDate), - "downloadURL": .string(downloadURL), - "fileSize": .int64(fileSize), - "sha256Hash": .string(sha256Hash), - "sha1Hash": .string(sha1Hash), - "isPrerelease": .from(isPrerelease), - "source": .string(source) - ] - - // Optional fields - if let isSigned { - fields["isSigned"] = .from(isSigned) - } - - if let notes { - fields["notes"] = .string(notes) - } - - if let sourceUpdatedAt { - fields["sourceUpdatedAt"] = .date(sourceUpdatedAt) - } - - return fields - } - - static func from(recordInfo: RecordInfo) -> Self? { - guard let version = recordInfo.fields["version"]?.stringValue, - let buildNumber = recordInfo.fields["buildNumber"]?.stringValue, - let releaseDate = recordInfo.fields["releaseDate"]?.dateValue, - let downloadURL = recordInfo.fields["downloadURL"]?.stringValue, - let fileSize = recordInfo.fields["fileSize"]?.intValue, - let sha256Hash = recordInfo.fields["sha256Hash"]?.stringValue, - let sha1Hash = recordInfo.fields["sha1Hash"]?.stringValue, - let source = recordInfo.fields["source"]?.stringValue - else { - return nil - } - - return RestoreImageRecord( - version: version, - buildNumber: buildNumber, - releaseDate: releaseDate, - downloadURL: downloadURL, - fileSize: fileSize, - sha256Hash: sha256Hash, - sha1Hash: sha1Hash, - isSigned: recordInfo.fields["isSigned"]?.boolValue, - isPrerelease: recordInfo.fields["isPrerelease"]?.boolValue ?? false, - source: source, - notes: recordInfo.fields["notes"]?.stringValue, - sourceUpdatedAt: recordInfo.fields["sourceUpdatedAt"]?.dateValue - ) - } - - static func formatForDisplay(_ recordInfo: RecordInfo) -> String { - let build = recordInfo.fields["buildNumber"]?.stringValue ?? "Unknown" - let signed = recordInfo.fields["isSigned"]?.boolValue ?? false - let prerelease = recordInfo.fields["isPrerelease"]?.boolValue ?? false - let source = recordInfo.fields["source"]?.stringValue ?? "Unknown" - let size = recordInfo.fields["fileSize"]?.intValue ?? 0 - - let signedStr = signed ? "✅ Signed" : "❌ Unsigned" - let prereleaseStr = prerelease ? "[Beta/RC]" : "" - let sizeStr = formatFileSize(size) - - var output = " \(build) \(prereleaseStr)\n" - output += " \(signedStr) | Size: \(sizeStr) | Source: \(source)" - return output - } - - private static func formatFileSize(_ bytes: Int) -> String { - let gb = Double(bytes) / 1_000_000_000 - if gb >= 1.0 { - return String(format: "%.2f GB", gb) - } else { - let mb = Double(bytes) / 1_000_000 - return String(format: "%.0f MB", mb) - } - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Models/SwiftVersionRecord.swift b/Examples/Bushel/Sources/BushelImages/Models/SwiftVersionRecord.swift deleted file mode 100644 index 81c8ee92..00000000 --- a/Examples/Bushel/Sources/BushelImages/Models/SwiftVersionRecord.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import MistKit - -/// Represents a Swift compiler release bundled with Xcode -struct SwiftVersionRecord: Codable, Sendable { - /// Swift version (e.g., "5.9", "5.10", "6.0") - var version: String - - /// Release date - var releaseDate: Date - - /// Optional swift.org toolchain download - var downloadURL: String? - - /// Beta/snapshot indicator - var isPrerelease: Bool - - /// Release notes - var notes: String? - - /// CloudKit record name based on version (e.g., "SwiftVersion-5.9.2") - var recordName: String { - "SwiftVersion-\(version)" - } -} - -// MARK: - CloudKitRecord Conformance - -extension SwiftVersionRecord: CloudKitRecord { - static var cloudKitRecordType: String { "SwiftVersion" } - - func toCloudKitFields() -> [String: FieldValue] { - var fields: [String: FieldValue] = [ - "version": .string(version), - "releaseDate": .date(releaseDate), - "isPrerelease": .from(isPrerelease) - ] - - // Optional fields - if let downloadURL { - fields["downloadURL"] = .string(downloadURL) - } - - if let notes { - fields["notes"] = .string(notes) - } - - return fields - } - - static func from(recordInfo: RecordInfo) -> Self? { - guard let version = recordInfo.fields["version"]?.stringValue, - let releaseDate = recordInfo.fields["releaseDate"]?.dateValue - else { - return nil - } - - return SwiftVersionRecord( - version: version, - releaseDate: releaseDate, - downloadURL: recordInfo.fields["downloadURL"]?.stringValue, - isPrerelease: recordInfo.fields["isPrerelease"]?.boolValue ?? false, - notes: recordInfo.fields["notes"]?.stringValue - ) - } - - static func formatForDisplay(_ recordInfo: RecordInfo) -> String { - let version = recordInfo.fields["version"]?.stringValue ?? "Unknown" - let releaseDate = recordInfo.fields["releaseDate"]?.dateValue - - let dateStr = releaseDate.map { formatDate($0) } ?? "Unknown" - - var output = "\n Swift \(version)\n" - output += " Released: \(dateStr)" - return output - } - - private static func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter.string(from: date) - } -} diff --git a/Examples/Bushel/Sources/BushelImages/Models/XcodeVersionRecord.swift b/Examples/Bushel/Sources/BushelImages/Models/XcodeVersionRecord.swift deleted file mode 100644 index a09b07e7..00000000 --- a/Examples/Bushel/Sources/BushelImages/Models/XcodeVersionRecord.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation -import MistKit - -/// Represents an Xcode release with macOS requirements and bundled Swift version -struct XcodeVersionRecord: Codable, Sendable { - /// Xcode version (e.g., "15.1", "15.2 Beta 3") - var version: String - - /// Build identifier (e.g., "15C65") - var buildNumber: String - - /// Release date - var releaseDate: Date - - /// Optional developer.apple.com download link - var downloadURL: String? - - /// Download size in bytes - var fileSize: Int? - - /// Beta/RC indicator - var isPrerelease: Bool - - /// Reference to minimum RestoreImage record required (recordName) - var minimumMacOS: String? - - /// Reference to bundled Swift compiler (recordName) - var includedSwiftVersion: String? - - /// JSON of SDK versions: {"macOS": "14.2", "iOS": "17.2", "watchOS": "10.2"} - var sdkVersions: String? - - /// Release notes or additional info - var notes: String? - - /// CloudKit record name based on build number (e.g., "XcodeVersion-15C65") - var recordName: String { - "XcodeVersion-\(buildNumber)" - } -} - -// MARK: - CloudKitRecord Conformance - -extension XcodeVersionRecord: CloudKitRecord { - static var cloudKitRecordType: String { "XcodeVersion" } - - func toCloudKitFields() -> [String: FieldValue] { - var fields: [String: FieldValue] = [ - "version": .string(version), - "buildNumber": .string(buildNumber), - "releaseDate": .date(releaseDate), - "isPrerelease": FieldValue(booleanValue: isPrerelease) - ] - - // Optional fields - if let downloadURL { - fields["downloadURL"] = .string(downloadURL) - } - - if let fileSize { - fields["fileSize"] = .int64(fileSize) - } - - if let minimumMacOS { - fields["minimumMacOS"] = .reference(FieldValue.Reference( - recordName: minimumMacOS, - action: nil - )) - } - - if let includedSwiftVersion { - fields["includedSwiftVersion"] = .reference(FieldValue.Reference( - recordName: includedSwiftVersion, - action: nil - )) - } - - if let sdkVersions { - fields["sdkVersions"] = .string(sdkVersions) - } - - if let notes { - fields["notes"] = .string(notes) - } - - return fields - } - - static func from(recordInfo: RecordInfo) -> Self? { - guard let version = recordInfo.fields["version"]?.stringValue, - let buildNumber = recordInfo.fields["buildNumber"]?.stringValue, - let releaseDate = recordInfo.fields["releaseDate"]?.dateValue - else { - return nil - } - - return XcodeVersionRecord( - version: version, - buildNumber: buildNumber, - releaseDate: releaseDate, - downloadURL: recordInfo.fields["downloadURL"]?.stringValue, - fileSize: recordInfo.fields["fileSize"]?.intValue, - isPrerelease: recordInfo.fields["isPrerelease"]?.boolValue ?? false, - minimumMacOS: recordInfo.fields["minimumMacOS"]?.referenceValue?.recordName, - includedSwiftVersion: recordInfo.fields["includedSwiftVersion"]?.referenceValue?.recordName, - sdkVersions: recordInfo.fields["sdkVersions"]?.stringValue, - notes: recordInfo.fields["notes"]?.stringValue - ) - } - - static func formatForDisplay(_ recordInfo: RecordInfo) -> String { - let version = recordInfo.fields["version"]?.stringValue ?? "Unknown" - let build = recordInfo.fields["buildNumber"]?.stringValue ?? "Unknown" - let releaseDate = recordInfo.fields["releaseDate"]?.dateValue - let size = recordInfo.fields["fileSize"]?.intValue ?? 0 - - let dateStr = releaseDate.map { formatDate($0) } ?? "Unknown" - let sizeStr = formatFileSize(size) - - var output = "\n \(version) (Build \(build))\n" - output += " Released: \(dateStr) | Size: \(sizeStr)" - return output - } - - private static func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter.string(from: date) - } - - private static func formatFileSize(_ bytes: Int) -> String { - let gb = Double(bytes) / 1_000_000_000 - if gb >= 1.0 { - return String(format: "%.2f GB", gb) - } else { - let mb = Double(bytes) / 1_000_000 - return String(format: "%.0f MB", mb) - } - } -} diff --git a/Examples/Bushel/XCODE_SCHEME_SETUP.md b/Examples/Bushel/XCODE_SCHEME_SETUP.md deleted file mode 100644 index 71b1dd0e..00000000 --- a/Examples/Bushel/XCODE_SCHEME_SETUP.md +++ /dev/null @@ -1,258 +0,0 @@ -# Xcode Scheme Setup for Bushel Demo - -This guide explains how to set up the Xcode scheme to run and debug the `bushel-images` CLI tool. - -## Opening the Package in Xcode - -1. Open Xcode -2. Go to **File > Open...** -3. Navigate to `/Users/leo/Documents/Projects/MistKit/Examples/Bushel/` -4. Select `Package.swift` and click **Open** - -Alternatively, from Terminal: -```bash -cd /Users/leo/Documents/Projects/MistKit/Examples/Bushel -open Package.swift -``` - -## Creating/Editing the Scheme - -### 1. Open Scheme Editor - -- Click the scheme selector in the toolbar (next to the Run/Stop buttons) -- Select **bushel-images** if it exists, or create a new scheme -- Click **Edit Scheme...** (or press `Cmd+Shift+,`) - -### 2. Configure Run Settings - -In the Scheme Editor, select **Run** in the left sidebar. - -#### Info Tab -- **Executable**: Select `bushel-images` -- **Build Configuration**: Debug -- **Debugger**: LLDB - -#### Arguments Tab - -**Environment Variables**: -Add the following environment variables: - -| Name | Value | Description | -|------|-------|-------------| -| `CLOUDKIT_CONTAINER_ID` | `iCloud.com.yourcompany.Bushel` | Your CloudKit container identifier | -| `CLOUDKIT_API_TOKEN` | `your-api-token-here` | Your CloudKit API token | - -**Arguments Passed On Launch**: -Add command-line arguments for testing different commands: - -For sync command: -``` -sync --container-id $(CLOUDKIT_CONTAINER_ID) --api-token $(CLOUDKIT_API_TOKEN) -``` - -For export command: -``` -export --container-id $(CLOUDKIT_CONTAINER_ID) --api-token $(CLOUDKIT_API_TOKEN) --output ./export.json -``` - -For help: -``` ---help -``` - -#### Options Tab -- **Working Directory**: - - Select **Use custom working directory** - - Set to: `/Users/leo/Documents/Projects/MistKit/Examples/Bushel` - -### 3. Configure Build Settings (Optional) - -In the Scheme Editor, select **Build** in the left sidebar: - -- Ensure `bushel-images` target is checked for **Run** -- Optionally check **Test** if you add tests later -- Ensure `MistKit` is listed as a dependency (should be automatic) - -## Getting CloudKit Credentials - -### CloudKit Container Identifier - -1. Open [CloudKit Dashboard](https://icloud.developer.apple.com/) -2. Sign in with your Apple Developer account -3. Select or create a container -4. The identifier format is: `iCloud.com.yourcompany.YourApp` - -### CloudKit API Token - -#### Option 1: API Token (Recommended for Development) - -1. In CloudKit Dashboard, select your container -2. Go to **API Access** tab -3. Click **API Tokens** -4. Click **Add API Token** -5. Give it a name (e.g., "Bushel Development") -6. Copy the token value - -#### Option 2: Server-to-Server Authentication (Production) - -For production use, you'll need: -- Key ID -- Private key file (.pem) -- Server-to-Server key authentication - -See MistKit documentation for server-to-server setup. - -## Environment Variables File (Alternative) - -Instead of adding environment variables to the scheme, you can create a `.env` file: - -```bash -# Create .env file in Bushel directory -cat > .env << 'EOF' -CLOUDKIT_CONTAINER_ID=iCloud.com.yourcompany.Bushel -CLOUDKIT_API_TOKEN=your-api-token-here -EOF - -# Don't commit this file! -echo ".env" >> .gitignore -``` - -Then modify the scheme to load environment from file (requires additional code). - -## Running the CLI - -### From Xcode - -1. Select the `bushel-images` scheme -2. Press `Cmd+R` to run -3. View output in the Console pane (bottom of Xcode) - -### From Terminal - -After building in Xcode, you can also run from Terminal: - -```bash -# Navigate to build products -cd /Users/leo/Documents/Projects/MistKit/Examples/Bushel/.build/arm64-apple-macosx/debug - -# Run with arguments -./bushel-images sync \ - --container-id "iCloud.com.yourcompany.Bushel" \ - --api-token "your-api-token-here" - -# Or set environment variables -export CLOUDKIT_CONTAINER_ID="iCloud.com.yourcompany.Bushel" -export CLOUDKIT_API_TOKEN="your-api-token-here" -./bushel-images sync --container-id $CLOUDKIT_CONTAINER_ID --api-token $CLOUDKIT_API_TOKEN -``` - -## Debugging Tips - -### Breakpoints - -1. Open relevant source files (e.g., `BushelCloudKitService.swift`) -2. Click in the gutter to set breakpoints -3. Run with `Cmd+R` -4. Execution will pause at breakpoints - -### Console Output - -The CLI uses `print()` statements to show progress: -- "Fetching X from Y..." -- "Syncing N record(s) in M batch(es)..." -- "✅ Synced N records" - -### Common Issues - -**Issue**: "Cannot find container" -- **Solution**: Verify container ID is correct in CloudKit Dashboard - -**Issue**: "Authentication failed" -- **Solution**: Check API token is valid and has correct permissions - -**Issue**: "Cannot find type 'RecordOperation'" -- **Solution**: Clean build folder (`Cmd+Shift+K`) and rebuild - -**Issue**: Module 'MistKit' not found -- **Solution**: Ensure MistKit is built first (should be automatic via dependencies) - -## Testing Without Real CloudKit - -To test the data fetching without CloudKit: - -1. Comment out the CloudKit sync calls in `SyncCommand.swift` -2. Add export of fetched data: - ```swift - // In SyncCommand.run() - let (restoreImages, xcodeVersions, swiftVersions) = try await engine.fetchAllData() - print("Fetched:") - print(" - \(restoreImages.count) restore images") - print(" - \(xcodeVersions.count) Xcode versions") - print(" - \(swiftVersions.count) Swift versions") - ``` - -## CloudKit Schema Setup - -Before running sync, ensure your CloudKit schema has the required record types: - -### RestoreImage Record Type -- `version` (String) -- `buildNumber` (String) -- `releaseDate` (Date/Time) -- `downloadURL` (String) -- `fileSize` (Int64) -- `sha256Hash` (String) -- `sha1Hash` (String) -- `isSigned` (Boolean) -- `isPrerelease` (Boolean) -- `source` (String) -- `notes` (String, optional) - -### XcodeVersion Record Type -- `version` (String) -- `buildNumber` (String) -- `releaseDate` (Date/Time) -- `isPrerelease` (Boolean) -- `downloadURL` (String, optional) -- `fileSize` (Int64, optional) -- `minimumMacOS` (Reference to RestoreImage, optional) -- `includedSwiftVersion` (Reference to SwiftVersion, optional) -- `sdkVersions` (String, optional) -- `notes` (String, optional) - -### SwiftVersion Record Type -- `version` (String) -- `releaseDate` (Date/Time) -- `isPrerelease` (Boolean) -- `downloadURL` (String, optional) -- `notes` (String, optional) - -You can create these schemas in CloudKit Dashboard > Schema section. - -## Next Steps - -1. Set up CloudKit container and get credentials -2. Configure the Xcode scheme with your credentials -3. Run the CLI to test data fetching (comment out CloudKit sync first) -4. Create CloudKit schema (record types) -5. Run full sync to populate CloudKit - -## Troubleshooting - -### Getting More Verbose Output - -Add `--verbose` flag support to commands if needed, or temporarily add debug prints: - -```swift -// In BushelCloudKitService.swift -print("DEBUG: Syncing batch with operations: \(batch.map { $0.recordName })") -``` - -### Viewing Network Requests - -Add logging middleware to MistKit (already configured) by setting environment variable: -``` -MISTKIT_DEBUG_LOGGING=1 -``` - -This will print all HTTP requests/responses to console. diff --git a/Examples/BushelCloud/.claude/MIGRATION_SWIFT_CONFIGURATION.md b/Examples/BushelCloud/.claude/MIGRATION_SWIFT_CONFIGURATION.md new file mode 100644 index 00000000..91e2175d --- /dev/null +++ b/Examples/BushelCloud/.claude/MIGRATION_SWIFT_CONFIGURATION.md @@ -0,0 +1,565 @@ +# Migration from ArgumentParser to Swift Configuration + +## Overview + +CelestraCloud migrated from Swift ArgumentParser to Apple's Swift Configuration library in December 2024. This document explains the motivation, process, and benefits of this migration. + +## Why We Migrated + +### Problems with ArgumentParser + +1. **Manual Parsing Overhead**: Required ~47 lines of manual parsing code in UpdateCommand +2. **Type Conversion**: Manual validation and error handling for each argument type +3. **No Environment Variable Support**: ArgumentParser only handles CLI arguments, requiring separate environment variable handling +4. **Duplicate Logic**: Had to maintain both CLI parsing and environment variable reading +5. **Error Handling**: Custom error messages for each validation failure + +### Benefits of Swift Configuration + +1. **Unified Configuration**: Single source handles both CLI arguments and environment variables +2. **Automatic Type Conversion**: Built-in parsing for String, Int, Double, Bool, Date (ISO8601) +3. **Provider Hierarchy**: Clear priority order (CLI > ENV > Defaults) +4. **Secrets Support**: Automatic redaction of sensitive values in logs +5. **Less Code**: Eliminated ~107 lines of manual parsing and conversion code +6. **Better Fault Tolerance**: Invalid values gracefully fall back to defaults + +## Understanding Package Traits + +### What are Package Traits? + +Package traits are opt-in features in Swift packages that allow you to enable additional functionality without including it by default. This keeps the base package lightweight while allowing users to opt into extra features as needed. + +### Available Swift Configuration Traits + +- **`JSON`** (default) - JSONSnapshot support +- **`Logging`** (opt-in) - AccessLogger for Swift Log integration +- **`Reloading`** (opt-in) - ReloadingFileProvider for auto-reloading config files +- **`CommandLineArguments`** (opt-in) - CommandLineArgumentsProvider for automatic CLI parsing +- **`YAML`** (opt-in) - YAMLSnapshot support + +**Note:** The `CommandLineArguments` trait is what enables `CommandLineArgumentsProvider`, which is the key feature we needed for this migration. + +## Migration Process + +### Phase 1: Enable Swift Configuration Package Trait + +**What Changed:** +```swift +// Package.swift - Before +.package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0") + +// Package.swift - After +.package( + url: "https://github.com/apple/swift-configuration.git", + from: "1.0.0", + traits: ["CommandLineArguments"] +) +``` + +**Why:** The `CommandLineArguments` trait enables `CommandLineArgumentsProvider` for automatic CLI parsing. The `.defaults` trait (JSON/FileProvider) is not needed and can cause Swift 6.2 compiler issues on Windows/Ubuntu builds. + +### Phase 2: Replace ConfigurationLoader + +**Before (ArgumentParser + Manual Parsing):** +```swift +public init(cliOverrides: [String: Any] = [:]) { + var providers: [any ConfigProvider] = [] + + // Manual conversion of CLI overrides + if !cliOverrides.isEmpty { + let configValues = Self.convertToConfigValues(cliOverrides) + providers.append(InMemoryProvider(name: "CLI", values: configValues)) + } + + providers.append(EnvironmentVariablesProvider()) + self.configReader = ConfigReader(providers: providers) +} + +// Required ~35 lines of convertToConfigValues() method +// Required ~5 lines of parseDateString() method +``` + +**After (Swift Configuration):** +```swift +public init() { + var providers: [any ConfigProvider] = [] + + // Automatic CLI argument parsing + providers.append(CommandLineArgumentsProvider( + secretsSpecifier: .specific([ + "--cloudkit-key-id", + "--cloudkit-private-key-path" + ]) + )) + + providers.append(EnvironmentVariablesProvider()) + self.configReader = ConfigReader(providers: providers) +} + +// No conversion methods needed! +``` + +**Code Reduction:** ~40 lines removed from ConfigurationLoader + +### Phase 3: Simplify UpdateCommand + +**Before (ArgumentParser):** +```swift +static func run(args: [String]) async throws { + var cliOverrides: [String: Any] = [:] + var i = 0 + while i < args.count { + let arg = args[i] + switch arg { + case "--update-delay": + guard i + 1 < args.count, let value = Double(args[i + 1]) else { + print("Error: --update-delay requires a numeric value") + throw ExitError() + } + cliOverrides["update.delay"] = value + i += 2 + case "--update-skip-robots-check": + cliOverrides["update.skip_robots_check"] = true + i += 1 + // ... 40 more lines of manual parsing + } + } + + let loader = ConfigurationLoader(cliOverrides: cliOverrides) + let config = try await loader.loadConfiguration() + // ... +} +``` + +**After (Swift Configuration):** +```swift +static func run(args: [String]) async throws { + // CommandLineArgumentsProvider automatically parses all arguments + let loader = ConfigurationLoader() + let config = try await loader.loadConfiguration() + // ... +} +``` + +**Code Reduction:** 47 lines removed from UpdateCommand + +### Phase 4: Date Handling Improvement + +**Before (Manual ISO8601 Parsing):** +```swift +private func parseDateString(_ value: String?) -> Date? { + guard let value = value else { return nil } + let formatter = ISO8601DateFormatter() + return formatter.date(from: value) +} + +// Usage +lastAttemptedBefore: parseDateString( + readString(forKey: "update.last_attempted_before") ?? + readString(forKey: "UPDATE_LAST_ATTEMPTED_BEFORE") +) +``` + +**After (Built-in Conversion):** +```swift +private func readDate(forKey key: String) -> Date? { + // Swift Configuration automatically converts ISO8601 strings to Date + configReader.string(forKey: ConfigKey(key), as: Date.self) +} + +// Usage +lastAttemptedBefore: readDate(forKey: "update.last_attempted_before") ?? + readDate(forKey: "UPDATE_LAST_ATTEMPTED_BEFORE") +``` + +**Benefits:** +- Built-in ISO8601 parsing (no manual DateFormatter) +- Consistent with other type conversions +- Graceful fallback on invalid dates + +## Behavior Changes + +### 1. Invalid Input Handling + +**Before:** +```bash +$ celestra-cloud update --update-delay abc +Error: --update-delay requires a numeric value +[Exit code 1] +``` + +**After:** +```bash +$ celestra-cloud update --update-delay abc +🔄 Starting feed update... + ⏱️ Rate limit: 2.0 seconds between feeds +# Falls back to default 2.0, continues execution +``` + +**Impact:** More fault-tolerant for production systems. + +### 2. Unknown Arguments + +**Before:** +```bash +$ celestra-cloud update --unknown-option +Unknown option: --unknown-option +[Exit code 1] +``` + +**After:** +```bash +$ celestra-cloud update --unknown-option +# Silently ignores unknown arguments +``` + +**Impact:** Better forward compatibility - adding new options doesn't break older clients. + +### 3. Secrets Handling + +**New Feature:** +```swift +CommandLineArgumentsProvider( + secretsSpecifier: .specific([ + "--cloudkit-key-id", + "--cloudkit-private-key-path" + ]) +) +``` + +CloudKit credentials are now automatically redacted in logs and debug output. + +## Configuration Key Mapping + +Swift Configuration automatically converts between formats: + +**CLI Arguments (kebab-case):** +```bash +--update-delay 3.0 +--update-skip-robots-check +--update-max-failures 5 +``` + +**Environment Variables (SCREAMING_SNAKE_CASE):** +```bash +UPDATE_DELAY=3.0 +UPDATE_SKIP_ROBOTS_CHECK=true +UPDATE_MAX_FAILURES=5 +``` + +**Internal Keys (dot.notation with underscores):** +``` +update.delay +update.skip_robots_check +update.max_failures +``` + +All conversions happen automatically! + +## CLI Argument Formats + +CommandLineArgumentsProvider supports multiple argument formats: + +### Supported Formats + +- `--key value` - Standard key-value pair (most common) +- `--key=value` - Equals-separated format +- `--key` - Boolean flag (presence = true) +- `--no-key` - Negative boolean flag (presence = false) + +### Examples + +```bash +# Standard format +--update-delay 3.0 + +# Equals format +--update-delay=3.0 + +# Boolean flags +--update-skip-robots-check # Sets skip_robots_check = true +--no-update-skip-robots-check # Sets skip_robots_check = false +``` + +### Array Handling + +While CelestraCloud doesn't currently use array configurations, CommandLineArgumentsProvider supports them for future use: + +```bash +# Multiple values for the same key create arrays +--ports 8080 --ports 8443 --ports 9000 +# Results in: ports = [8080, 8443, 9000] +``` + +**Note:** CelestraCloud uses `Int` (not `Int64`) for counts like `max_failures` and `min_popularity` as they are natural Swift integers. + +This could be useful for future features like: +- Multiple feed URLs for batch operations +- List of allowed domains +- Collection of API endpoints + +## Testing the Migration + +### Test 1: CLI Arguments +```bash +swift run celestra-cloud update --update-delay 3.5 +# Should output: "Rate limit: 3.5 seconds" +``` + +### Test 2: Environment Variables +```bash +UPDATE_DELAY=3.7 swift run celestra-cloud update +# Should output: "Rate limit: 3.7 seconds" +``` + +### Test 3: Priority (CLI > ENV) +```bash +UPDATE_DELAY=2.0 swift run celestra-cloud update --update-delay 5.0 +# Should output: "Rate limit: 5.0 seconds" (CLI wins) +``` + +### Test 4: Invalid Input (Graceful Fallback) +```bash +swift run celestra-cloud update --update-delay abc +# Should output: "Rate limit: 2.0 seconds" (default fallback) +``` + +All tests passed successfully ✅ + +## Code Metrics + +| Metric | Before | After | Change | +|--------|--------|-------|--------| +| ConfigurationLoader.swift lines | ~160 | ~120 | -40 lines | +| UpdateCommand.swift parsing | ~47 | 0 | -47 lines | +| Total parsing code | ~107 | 0 | -107 lines | +| Dependencies | ArgumentParser | Swift Configuration | Replaced | + +## Comprehensive Advantages & Considerations + +### ✅ Advantages of CommandLineArgumentsProvider + +1. **Dramatic Code Reduction**: Eliminated ~107 lines of manual parsing and conversion code +2. **Automatic Type Conversion**: Built-in parsing for String, Int, Double, Bool, Date (ISO8601) with no manual validators +3. **Better Error Messages**: Framework provides consistent validation and error reporting +4. **Array Support**: Automatically handles multiple values for the same key +5. **Secrets Handling**: Built-in support for sensitive values with automatic redaction +6. **Consistent Behavior**: Same parsing logic used across all Apple tools and ecosystem +7. **Zero-Maintenance Parsing**: Adding new configuration options requires no parsing code +8. **Multiple Format Support**: Handles `--key value`, `--key=value`, and boolean flags +9. **Unified Configuration Model**: Single ConfigurationLoader handles both CLI and ENV seamlessly +10. **Better Fault Tolerance**: Invalid values gracefully fall back to defaults instead of crashing +11. **Forward Compatible**: Unknown arguments are ignored, allowing newer CLIs with older commands + +### ⚠️ Considerations + +1. **Trait Dependency**: Requires enabling `CommandLineArguments` package trait (minimal overhead, one-line change) +2. **Compatibility**: Requires Swift Configuration 1.0+ (already a dependency) +3. **Key Format**: Must use `--kebab-case` format for CLI arguments (already standard practice) +4. **Behavior Change**: Invalid input now falls back to defaults instead of erroring (documented as improvement) +5. **Unknown Arguments**: No longer errors on unknown options (better for forward compatibility) + +**Overall Assessment**: The advantages significantly outweigh the minimal considerations. The migration resulted in cleaner, more maintainable, and more robust code. + +## Best Practices Learned + +### Type Choices +- **Use `Int` not `Int64`** for counts, thresholds, and natural integers + - More idiomatic Swift + - Simpler API (no conversion needed) + - Only use `Int64` when CloudKit schema requires it (stored as INT64) + +### File Organization +- **One type per file** from the start +- Extract key constants into `ConfigurationKeys.swift` early +- Separate "loaded" config (optional fields) from "validated" config (non-optional) + +### Configuration Patterns +- **Dual-key fallback**: Always check both CLI and ENV keys + ```swift + readString(forKey: ConfigurationKeys.Update.delay) ?? + readString(forKey: ConfigurationKeys.Update.delayEnv) ?? defaultValue + ``` +- **Validation method**: Add `validated()` to catch missing required fields early +- **Secrets handling**: Always use `secretsSpecifier` for credentials + +## Migration Lessons Learned + +### What Went Well + +1. **Smooth Trait Enablement**: Package trait system worked perfectly +2. **Type Safety Maintained**: All type conversions remained safe +3. **No Breaking Changes**: Users can still use environment variables exactly as before +4. **Better DX**: Adding new options now requires zero parsing code + +### Challenges + +1. **Trait Name Confusion**: Initial attempt used `CommandLineArgumentsSupport` instead of `CommandLineArguments` +2. **Documentation Gap**: Had to reference Swift Configuration docs for ISO8601 date conversion behavior +3. **Behavior Change**: Users expecting errors on invalid input now get graceful fallbacks (documented as improvement) + +## Recommendations for Future Migrations + +1. **Enable Package Traits Early**: Check `swift test --enable-all-traits` to find trait names +2. **Test Priority Order**: Verify CLI > ENV > Defaults works correctly +3. **Document Behavior Changes**: Clearly explain differences in error handling +4. **Keep Environment Variables**: Don't force users to change their setup +5. **Add Secrets Handling**: Use `secretsSpecifier` for sensitive configuration + +## For New Projects (e.g., BushelCloud) + +If you're starting a new CLI project, you should **start with Swift Configuration from day one** rather than migrating later. Here's the recommended approach: + +### Initial Setup + +1. **Add Swift Configuration to Package.swift with Trait**: + ```swift + dependencies: [ + .package( + url: "https://github.com/apple/swift-configuration.git", + from: "1.0.0", + traits: ["CommandLineArguments"] + ) + ] + ``` + +2. **Create Configuration Structures** (similar to CelestraCloud): + - Root configuration struct (e.g., `BushelConfiguration`) + - CloudKit configuration struct + - Command-specific configuration structs + +3. **Create ConfigurationLoader Actor**: + ```swift + public actor ConfigurationLoader { + public init() { + var providers: [any ConfigProvider] = [] + + // Priority 1: Command-line arguments + providers.append(CommandLineArgumentsProvider( + secretsSpecifier: .specific([ + "--cloudkit-key-id", + "--cloudkit-private-key-path" + ]) + )) + + // Priority 2: Environment variables + providers.append(EnvironmentVariablesProvider()) + + self.configReader = ConfigReader(providers: providers) + } + } + ``` + +4. **No Manual Parsing Needed**: Just load configuration and use it: + ```swift + enum MyCommand { + static func run(args: [String]) async throws { + let loader = ConfigurationLoader() + let config = try await loader.loadConfiguration() + // Use config.myOption directly! + } + } + ``` + +### Key Benefits of Starting Fresh + +- **Zero manual parsing code** from the beginning +- **Consistent patterns** across all commands +- **Built-in secrets handling** from day one +- **No migration needed** in the future +- **Reference CelestraCloud** as a working example + +### What to Copy from CelestraCloud + +1. **Configuration structure pattern**: See `Sources/CelestraCloudKit/Configuration/` +2. **ConfigurationLoader implementation**: See `ConfigurationLoader.swift` +3. **Configuration key constants pattern**: See the `ConfigKeys` nested enum +4. **Secrets specification**: See `secretsSpecifier` usage +5. **Command integration pattern**: See `UpdateCommand.swift` for how to use config + +### Recommended Patterns + +#### ConfigurationKeys Enum (One type per file: `ConfigurationKeys.swift`) +```swift +/// Configuration keys for reading from providers +internal enum ConfigurationKeys { + internal enum CloudKit { + internal static let containerID = "cloudkit.container_id" + internal static let containerIDEnv = "CLOUDKIT_CONTAINER_ID" + internal static let keyID = "cloudkit.key_id" + internal static let keyIDEnv = "CLOUDKIT_KEY_ID" + // ... more keys + } + + internal enum YourCommand { + internal static let someOption = "yourcommand.some_option" + internal static let someOptionEnv = "YOURCOMMAND_SOME_OPTION" + } +} +``` + +**Benefits**: Centralized key definitions, type-safe access, clear CLI vs ENV naming. + +**Note for BushelCloud**: This pattern uses string-based keys for simplicity. You may want to explore stronger typing (e.g., `ConfigKey` with associated value types) for additional compile-time safety. See CelestraCloud implementation first to understand the basic pattern. + +#### Validation Pattern (in your configuration struct) +```swift +public struct CloudKitConfiguration: Sendable { + public var containerID: String? + public var keyID: String? + // ... + + /// Validate that all required fields are present + public func validated() throws -> ValidatedCloudKitConfiguration { + guard let containerID = containerID, !containerID.isEmpty else { + throw ConfigurationError( + "CloudKit container ID required", + key: "cloudkit.container_id" + ) + } + // ... validate other required fields + return ValidatedCloudKitConfiguration( + containerID: containerID, + keyID: keyID, + // ... + ) + } +} + +public struct ValidatedCloudKitConfiguration: Sendable { + public let containerID: String // Non-optional! + public let keyID: String // Non-optional! + // ... +} +``` + +**Benefits**: Type safety (commands receive validated config with non-optional required fields), better error messages. + +#### Quick Start Checklist for BushelCloud +- [ ] Add `swift-configuration` dependency with `traits: ["CommandLineArguments"]` +- [ ] Create `ConfigurationKeys.swift` enum +- [ ] Create configuration structs (`YourConfiguration`, `CloudKitConfiguration`, etc.) +- [ ] Add `validated()` method to configs with required fields +- [ ] Create `ValidatedConfiguration` struct with non-optional required fields +- [ ] Create `ConfigurationLoader` actor using `CommandLineArgumentsProvider` +- [ ] Use dual-key fallback pattern: `readString(forKey: .option) ?? readString(forKey: .optionEnv)` +- [ ] Test with CLI args, ENV vars, and mixed mode + +### Testing Trait Availability + +```bash +# Verify all traits are available during development +swift test --enable-all-traits +``` + +## References + +- [Swift Configuration Documentation](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration) +- [CommandLineArgumentsProvider API](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider) +- [Package Traits Documentation](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0387-package-traits.md) + +## Timeline + +- **December 2024**: Migration completed +- **Total Duration**: ~2 hours (planning, implementation, testing) +- **Commit**: See git history for exact changes diff --git a/Examples/BushelCloud/.claude/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md b/Examples/BushelCloud/.claude/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md new file mode 100644 index 00000000..d3c4c9b8 --- /dev/null +++ b/Examples/BushelCloud/.claude/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md @@ -0,0 +1,13333 @@ + + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration + +Library + +# Configuration + +A Swift library for reading configuration in applications and libraries. + +## Overview + +Swift Configuration defines an abstraction between configuration _readers_ and _providers_. + +Applications and libraries _read_ configuration through a consistent API, while the actual _provider_ is set up once at the application’s entry point. + +For example, to read the timeout configuration value for an HTTP client, check out the following examples using different providers: + +# Environment variables: +HTTP_TIMEOUT=30 +let provider = EnvironmentVariablesProvider() +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +# Program invoked with: +program --http-timeout 30 +let provider = CommandLineArgumentsProvider() +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +{ +"http": { +"timeout": 30 +} +} + +filePath: "/etc/config.json" +) +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +{ +"http": { +"timeout": 30 +} +} + +filePath: "/etc/config.json" +) +// Omitted: Add `provider` to a ServiceGroup +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +http: +timeout: 30 + +filePath: "/etc/config.yaml" +) +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +http: +timeout: 30 + +filePath: "/etc/config.yaml" +) +// Omitted: Add `provider` to a ServiceGroup +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +/ +|-- run +|-- secrets +|-- http-timeout + +Contents of the file `/run/secrets/http-timeout`: `30`. + +let provider = try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +// Environment variables consulted first, then JSON. +let primaryProvider = EnvironmentVariablesProvider() + +filePath: "/etc/config.json" +) +let config = ConfigReader(providers: [\ +primaryProvider,\ +secondaryProvider\ +]) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +let provider = InMemoryProvider(values: [\ +"http.timeout": 30,\ +]) +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 + +For a selection of more detailed examples, read through Example use cases. + +For a video introduction, check out our talk on YouTube. + +These providers can be combined to form a hierarchy, for details check out Provider hierarchy. + +### Quick start + +Add the dependency to your `Package.swift`: + +.package(url: "https://github.com/apple/swift-configuration", from: "1.0.0") + +Add the library dependency to your target: + +.product(name: "Configuration", package: "swift-configuration") + +Import and use in your code: + +import Configuration + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print("The HTTP timeout is: \(httpTimeout)") + +### Package traits + +This package offers additional integrations you can enable using package traits. To enable an additional trait on the package, update the package dependency: + +.package( +url: "https://github.com/apple/swift-configuration", +from: "1.0.0", ++ traits: [.defaults, "YAML"] +) + +Available traits: + +- **`JSON`** (default): Adds support for `JSONSnapshot`, which enables using `FileProvider` and `ReloadingFileProvider` with JSON files. + +- **`Logging`** (opt-in): Adds support for `AccessLogger`, a way to emit access events into a Swift Log `Logger`. + +- **`Reloading`** (opt-in): Adds support for `ReloadingFileProvider`, which provides auto-reloading capability for file-based configuration. + +- **`CommandLineArguments`** (opt-in): Adds support for `CommandLineArgumentsProvider` for parsing command line arguments. + +- **`YAML`** (opt-in): Adds support for `YAMLSnapshot`, which enables using `FileProvider` and `ReloadingFileProvider` with YAML files. + +### Supported platforms and minimum versions + +The library is supported on Apple platforms and Linux. + +| Component | macOS | Linux | iOS | tvOS | watchOS | visionOS | +| --- | --- | --- | --- | --- | --- | --- | +| Configuration | ✅ 15+ | ✅ | ✅ 18+ | ✅ 18+ | ✅ 11+ | ✅ 2+ | + +#### Three access patterns + +The library provides three distinct ways to read configuration values: + +- **Get**: Synchronously return the current value available locally, in memory: + +let timeout = config.int(forKey: "http.timeout", default: 60) + +- **Fetch**: Asynchronously get the most up-to-date value from disk or a remote server: + +let timeout = try await config.fetchInt(forKey: "http.timeout", default: 60) + +- **Watch**: Receive updates when a configuration value changes: + +try await config.watchInt(forKey: "http.timeout", default: 60) { updates in +for try await timeout in updates { +print("HTTP timeout updated to: \(timeout)") +} +} + +For detailed guidance on when to use each access pattern, see Choosing the access pattern. Within each of the access patterns, the library offers different reader methods that reflect your needs of optional, default, and required configuration parameters. To understand the choices available, see Choosing reader methods. + +#### Providers + +The library includes comprehensive built-in provider support: + +- Environment variables: `EnvironmentVariablesProvider` + +- Command-line arguments: `CommandLineArgumentsProvider` + +- JSON file: `FileProvider` and `ReloadingFileProvider` with `JSONSnapshot` + +- YAML file: `FileProvider` and `ReloadingFileProvider` with `YAMLSnapshot` + +- Directory of files: `DirectoryFilesProvider` + +- In-memory: `InMemoryProvider` and `MutableInMemoryProvider` + +- Key transforming: `KeyMappingProvider` + +You can also implement a custom `ConfigProvider`. + +#### Provider hierarchy + +In addition to using providers individually, you can create fallback behavior using an array of providers. The first provider that returns a non-nil value wins. + +The following example shows a provider hierarchy where environment variables take precedence over command line arguments, a JSON file, and in-memory defaults: + +// Create a hierarchy of providers with fallback behavior. +let config = ConfigReader(providers: [\ +// First, check environment variables.\ +EnvironmentVariablesProvider(),\ +// Then, check command-line options.\ +CommandLineArgumentsProvider(),\ +// Then, check a JSON config file.\ + +// Finally, fall \ +]) + +// Uses the first provider that has a value for "http.timeout". +let timeout = config.int(forKey: "http.timeout", default: 15) + +#### Hot reloading + +Long-running services can periodically reload configuration with `ReloadingFileProvider`: + +// Omitted: add provider to a ServiceGroup +let config = ConfigReader(provider: provider) + +Read Using reloading providers for details on how to receive updates as configuration changes. + +#### Namespacing and scoped readers + +The built-in namespacing of `ConfigKey` interprets `"http.timeout"` as an array of two components: `"http"` and `"timeout"`. The following example uses `scoped(to:)` to create a namespaced reader with the key `"http"`, to allow reads to use the shorter key `"timeout"`: + +Consider the following JSON configuration: + +{ +"http": { +"timeout": 60 +} +} +// Create the root reader. +let config = ConfigReader(provider: provider) + +// Create a scoped reader for HTTP settings. +let httpConfig = config.scoped(to: "http") + +// Now you can access values with shorter keys. +// Equivalent to reading "http.timeout" on the root reader. +let timeout = httpConfig.int(forKey: "timeout") + +#### Debugging and troubleshooting + +Debugging with `AccessReporter` makes it possible to log all accesses to a config reader: + +let logger = Logger(label: "config") +let config = ConfigReader( +provider: provider, +accessReporter: AccessLogger(logger: logger) +) +// Now all configuration access is logged, with secret values redacted + +You can also add the following environment variable, and emit log accesses into a file without any code changes: + +CONFIG_ACCESS_LOG_FILE=/var/log/myapp/config-access.log + +and then read the file: + +tail -f /var/log/myapp/config-access.log + +Check out the built-in `AccessLogger`, `FileAccessLogger`, and Troubleshooting and access reporting. + +#### Secrets handling + +The library provides built-in support for handling sensitive configuration values securely: + +// Mark sensitive values as secrets to prevent them from appearing in logs +let privateKey = try snapshot.requiredString(forKey: "mtls.privateKey", isSecret: true) +let optionalAPIToken = config.string(forKey: "api.token", isSecret: true) + +When values are marked as secrets, they are automatically redacted from access logs and debugging output. Read Handling secrets correctly for guidance on best practices for secrets management. + +#### Consistent snapshots + +Retrieve related values from a consistent snapshot using `ConfigSnapshotReader`, which you get by calling `snapshot()`. + +This ensures that multiple values are read from a single snapshot inside each provider, even when using providers that update their internal values. For example by downloading new data periodically: + +let config = /* a reader with one or more providers that change values over time */ +let snapshot = config.snapshot() +let certificate = try snapshot.requiredString(forKey: "mtls.certificate") +let privateKey = try snapshot.requiredString(forKey: "mtls.privateKey", isSecret: true) +// `certificate` and `privateKey` are guaranteed to come from the same snapshot in the provider + +#### Extensible ecosystem + +Any package can implement a `ConfigProvider`, making the ecosystem extensible for custom configuration sources. + +## Topics + +### Essentials + +Configuring applications + +Provide flexible and consistent configuration for your application. + +Configuring libraries + +Provide a consistent and flexible way to configure your library. + +Example use cases + +Review common use cases with ready-to-copy code samples. + +Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +### Configuration keys + +`struct ConfigKey` + +A configuration key representing a relative path to a configuration value. + +`struct AbsoluteConfigKey` + +A configuration key that represents an absolute path to a configuration value. + +`enum ConfigContextValue` + +A value that can be stored in a configuration context. + +### Troubleshooting and access reporting + +Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +### Value conversion + +`protocol ExpressibleByConfigString` + +A protocol for types that can be initialized from configuration string values. + +`protocol ConfigBytesFromStringDecoder` + +A protocol for decoding string configuration values into byte arrays. + +`struct ConfigBytesFromBase64StringDecoder` + +A decoder that converts base64-encoded strings into byte arrays. + +`struct ConfigBytesFromHexStringDecoder` + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +### Contributing + +Developing Swift Configuration + +Learn about tools and conventions used to develop the Swift Configuration package. + +Collaborate on API changes to Swift Configuration by writing a proposal. + +### Extended Modules + +Foundation + +SystemPackage + +- Configuration +- Overview +- Quick start +- Package traits +- Supported platforms and minimum versions +- Key features +- Topics + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/handling-secrets-correctly + +- Configuration +- Handling secrets correctly + +Article + +# Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +## Overview + +Swift Configuration provides built-in support for marking sensitive values as secrets. Secret values are automatically redacted by access reporters to prevent accidental disclosure of API keys, passwords, and other sensitive information. + +### Marking values as secret when reading + +Use the `isSecret` parameter on any configuration reader method to mark a value as secret: + +let config = ConfigReader(provider: provider) + +// Mark sensitive values as secret +let apiKey = try config.requiredString( +forKey: "api.key", +isSecret: true +) +let dbPassword = config.string( +forKey: "database.password", +isSecret: true +) + +// Regular values don't need the parameter +let serverPort = try config.requiredInt(forKey: "server.port") +let logLevel = config.string( +forKey: "log.level", +default: "info" +) + +This works with all access patterns and method variants: + +// Works with fetch and watch too +let latestKey = try await config.fetchRequiredString( +forKey: "api.key", +isSecret: true +) + +try await config.watchString( +forKey: "api.key", +isSecret: true +) { updates in +for await key in updates { +// Handle secret key updates +} +} + +### Provider-level secret specification + +Use `SecretsSpecifier` to automatically mark values as secret based on keys or content when creating providers: + +#### Mark all values as secret + +The following example marks all configuration read by the `DirectoryFilesProvider` as secret: + +let provider = DirectoryFilesProvider( +directoryPath: "/run/secrets", +secretsSpecifier: .all +) + +#### Mark specific keys as secret + +The following example marks three specific keys from a provider as secret: + +let provider = EnvironmentVariablesProvider( +secretsSpecifier: .specific(["API_KEY", "DATABASE_PASSWORD", "JWT_SECRET"]) +) + +#### Dynamic secret detection + +The following example marks keys as secret based on the closure you provide. In this case, keys that contain `password`, `secret`, or `token` are all marked as secret: + +filePath: "/etc/config.json", +secretsSpecifier: .dynamic { key, value in +key.lowercased().contains("password") || +key.lowercased().contains("secret") || +key.lowercased().contains("token") +} +) + +#### No secret values + +The following example asserts that none of the values returned from the provider are considered secret: + +filePath: "/etc/config.json", +secretsSpecifier: .none +) + +### For provider implementors + +When implementing a custom `ConfigProvider`, use the `ConfigValue` type’s `isSecret` property: + +// Create a secret value +let secretValue = ConfigValue("sensitive-data", isSecret: true) + +// Create a regular value +let regularValue = ConfigValue("public-data", isSecret: false) + +Set the `isSecret` property to `true` when your provider knows the values are read from a secrets store and must not be logged. + +### How secret values are protected + +Secret values are automatically handled by: + +- **`AccessLogger`** and **`FileAccessLogger`**: Redact secret values in logs. + +print(provider) + +### Best practices + +1. **Mark all sensitive data as secret**: API keys, passwords, tokens, private keys, connection strings. + +2. **Use provider-level specification** when you know which keys are always secret. + +3. **Use reader-level marking** for context-specific secrets or when the same key might be secret in some contexts but not others. + +4. **Be conservative**: When in doubt, mark values as secret. It’s safer than accidentally leaking sensitive data. + +For additional guidance on configuration security and overall best practices, see Adopting best practices. To debug issues with secret redaction in access logs, check out Troubleshooting and access reporting. When selecting between required, optional, and default method variants for secret values, refer to Choosing reader methods. + +## See Also + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +- Handling secrets correctly +- Overview +- Marking values as secret when reading +- Provider-level secret specification +- For provider implementors +- How secret values are protected +- Best practices +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot + +- Configuration +- YAMLSnapshot + +Class + +# YAMLSnapshot + +A snapshot of configuration values parsed from YAML data. + +final class YAMLSnapshot + +YAMLSnapshot.swift + +## Mentioned in + +Using reloading providers + +## Overview + +This class represents a point-in-time view of configuration values. It handles the conversion from YAML types to configuration value types. + +## Usage + +Use with `FileProvider` or `ReloadingFileProvider`: + +let config = ConfigReader(provider: provider) + +## Topics + +### Creating a YAML snapshot + +`convenience init(data: RawSpan, providerName: String, parsingOptions: YAMLSnapshot.ParsingOptions) throws` + +`struct ParsingOptions` + +Custom input configuration for YAML snapshot creation. + +### Snapshot configuration + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +### Instance Properties + +`let providerName: String` + +The name of the provider that created this snapshot. + +## Relationships + +### Conforms To + +- `ConfigSnapshot` +- `FileConfigSnapshot` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- YAMLSnapshot +- Mentioned in +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting + +Library + +# ConfigurationTesting + +A set of testing utilities for Swift Configuration adopters. + +## Overview + +This testing library adds a Swift Testing-based `ConfigProvider` compatibility suite, recommended for implementors of custom configuration providers. + +## Topics + +### Structures + +`struct ProviderCompatTest` + +A comprehensive test suite for validating `ConfigProvider` implementations. + +- ConfigurationTesting +- Overview +- Topics + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accesslogger + +- Configuration +- AccessLogger + +Class + +# AccessLogger + +An access reporter that logs configuration access events using the Swift Log API. + +final class AccessLogger + +AccessLogger.swift + +## Mentioned in + +Handling secrets correctly + +Troubleshooting and access reporting + +Configuring libraries + +## Overview + +This reporter integrates with the Swift Log library to provide structured logging of configuration accesses. Each configuration access generates a log entry with detailed metadata about the operation, making it easy to track configuration usage and debug issues. + +## Package traits + +This type is guarded by the `Logging` package trait. + +## Usage + +Create an access logger and pass it to your configuration reader: + +import Logging + +let logger = Logger(label: "config.access") +let accessLogger = AccessLogger(logger: logger, level: .info) +let config = ConfigReader( +provider: EnvironmentVariablesProvider(), +accessReporter: accessLogger +) + +## Log format + +Each access event generates a structured log entry with metadata including: + +- `kind`: The type of access operation (get, fetch, watch). + +- `key`: The configuration key that was accessed. + +- `location`: The source code location where the access occurred. + +- `value`: The resolved configuration value (redacted for secrets). + +- `counter`: An incrementing counter for tracking access frequency. + +- Provider-specific information for each provider in the hierarchy. + +## Topics + +### Creating an access logger + +`init(logger: Logger, level: Logger.Level, message: Logger.Message)` + +Creates a new access logger that reports configuration access events. + +## Relationships + +### Conforms To + +- `AccessReporter` +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +- AccessLogger +- Mentioned in +- Overview +- Package traits +- Usage +- Log format +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider + +- Configuration +- ReloadingFileProvider + +Class + +# ReloadingFileProvider + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +ReloadingFileProvider.swift + +## Mentioned in + +Using reloading providers + +Choosing the access pattern + +Troubleshooting and access reporting + +## Overview + +`ReloadingFileProvider` is a generic file-based configuration provider that monitors a configuration file for changes and automatically reloads the data when the file is modified. This provider works with different file formats by using different snapshot types that conform to `FileConfigSnapshot`. + +## Usage + +Create a reloading provider by specifying the snapshot type and file path: + +// Using with a JSON snapshot and a custom poll interval + +filePath: "/etc/config.json", +pollInterval: .seconds(30) +) + +// Using with a YAML snapshot + +filePath: "/etc/config.yaml" +) + +## Service integration + +This provider implements the `Service` protocol and must be run within a `ServiceGroup` to enable automatic reloading: + +let serviceGroup = ServiceGroup(services: [provider], logger: logger) +try await serviceGroup.run() + +The provider monitors the file by polling at the specified interval (default: 15 seconds) and notifies any active watchers when changes are detected. + +## Configuration from a reader + +You can also initialize the provider using a configuration reader: + +let envConfig = ConfigReader(provider: EnvironmentVariablesProvider()) + +This expects a `filePath` key in the configuration that specifies the path to the file. For a full list of configuration keys, check out `init(snapshotType:parsingOptions:config:)`. + +## File monitoring + +The provider detects changes by monitoring both file timestamps and symlink target changes. When a change is detected, it reloads the file and notifies all active watchers of the updated configuration values. + +## Topics + +### Creating a reloading file provider + +`convenience init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, allowMissing: Bool, pollInterval: Duration, logger: Logger, metrics: any MetricsFactory) async throws` + +Creates a reloading file provider that monitors the specified file path. + +`convenience init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, config: ConfigReader, logger: Logger, metrics: any MetricsFactory) async throws` + +Creates a reloading file provider using configuration from a reader. + +### Service lifecycle + +`func run() async throws` + +### Monitoring file changes + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +### Instance Properties + +`let providerName: String` + +The human-readable name of the provider. + +## Relationships + +### Conforms To + +- `ConfigProvider` +Conforms when `Snapshot` conforms to `FileConfigSnapshot`. + +- `ServiceLifecycle.Service` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- ReloadingFileProvider +- Mentioned in +- Overview +- Usage +- Service integration +- Configuration from a reader +- File monitoring +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot + +- Configuration +- JSONSnapshot + +Structure + +# JSONSnapshot + +A snapshot of configuration values parsed from JSON data. + +struct JSONSnapshot + +JSONSnapshot.swift + +## Mentioned in + +Example use cases + +Using reloading providers + +## Overview + +This structure represents a point-in-time view of configuration values. It handles the conversion from JSON types to configuration value types. + +## Usage + +Use with `FileProvider` or `ReloadingFileProvider`: + +let config = ConfigReader(provider: provider) + +## Topics + +### Creating a JSON snapshot + +`init(data: RawSpan, providerName: String, parsingOptions: JSONSnapshot.ParsingOptions) throws` + +`struct ParsingOptions` + +Parsing options for JSON snapshot creation. + +### Snapshot configuration + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +### Instance Properties + +`let providerName: String` + +The name of the provider that created this snapshot. + +## Relationships + +### Conforms To + +- `ConfigSnapshot` +- `FileConfigSnapshot` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- JSONSnapshot +- Mentioned in +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider + +- Configuration +- FileProvider + +Structure + +# FileProvider + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +FileProvider.swift + +## Mentioned in + +Example use cases + +Troubleshooting and access reporting + +## Overview + +`FileProvider` is a generic file-based configuration provider that works with different file formats by using different snapshot types that conform to `FileConfigSnapshot`. This allows for a unified interface for reading JSON, YAML, or other structured configuration files. + +## Usage + +Create a provider by specifying the snapshot type and file path: + +// Using with a JSON snapshot + +filePath: "/etc/config.json" +) + +// Using with a YAML snapshot + +filePath: "/etc/config.yaml" +) + +The provider reads the file once during initialization and creates an immutable snapshot of the configuration values. For auto-reloading behavior, use `ReloadingFileProvider`. + +## Configuration from a reader + +You can also initialize the provider using a configuration reader that specifies the file path through environment variables or other configuration sources: + +let envConfig = ConfigReader(provider: EnvironmentVariablesProvider()) + +This expects a `filePath` key in the configuration that specifies the path to the file. For a full list of configuration keys, check out `init(snapshotType:parsingOptions:config:)`. + +## Topics + +### Creating a file provider + +`init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, allowMissing: Bool) async throws` + +Creates a file provider that reads from the specified file path. + +`init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, config: ConfigReader) async throws` + +Creates a file provider using a file path from a configuration reader. + +### Reading configuration files + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +## Relationships + +### Conforms To + +- `ConfigProvider` +Conforms when `Snapshot` conforms to `FileConfigSnapshot`. + +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- FileProvider +- Mentioned in +- Overview +- Usage +- Configuration from a reader +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/example-use-cases + +- Configuration +- Example use cases + +Article + +# Example use cases + +Review common use cases with ready-to-copy code samples. + +## Overview + +For complete working examples with step-by-step instructions, see the Examples directory in the repository. + +### Reading from environment variables + +Use `EnvironmentVariablesProvider` to read configuration values from environment variables where your app launches. The following example creates a `ConfigReader` with an environment variable provider, and reads the key `server.port`, providing a default value of `8080`: + +import Configuration + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let port = config.int(forKey: "server.port", default: 8080) + +The default environment key encoder uses an underscore to separate key components, making the environment variable name above `SERVER_PORT`. + +### Reading from a JSON configuration file + +You can store multiple configuration values together in a JSON file and read them from the fileystem using `FileProvider` with `JSONSnapshot`. The following example creates a `ConfigReader` for a JSON file at the path `/etc/config.json`, and reads a url and port number collected as properties of the `database` JSON object: + +let config = ConfigReader( + +) + +// Access nested values using dot notation. +let databaseURL = config.string(forKey: "database.url", default: "localhost") +let databasePort = config.int(forKey: "database.port", default: 5432) + +The matching JSON for this configuration might look like: + +{ +"database": { +"url": "localhost", +"port": 5432 +} +} + +### Reading from a directory of secret files + +Use the `DirectoryFilesProvider` to read multiple values collected together in a directory on the fileystem, each in a separate file. The default directory key encoder uses a hyphen in the filename to separate key components. The following example uses the directory `/run/secrets` as a base, and reads the file `database-password` as the key `database.password`: + +// Common pattern for secrets downloaded by an init container. +let config = ConfigReader( +provider: try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) +) + +// Reads the file `/run/secrets/database-password` +let dbPassword = config.string(forKey: "database.password") + +This pattern is useful for reading secrets that your infrastructure makes available on the file system, such as Kubernetes secrets mounted into a container’s filesystem. + +### Handling optional configuration files + +File-based providers support an `allowMissing` parameter to control whether missing files should throw an error or be treated as empty configuration. This is useful when configuration files are optional. + +When `allowMissing` is `false` (the default), missing files throw an error: + +// This will throw an error if config.json doesn't exist +let config = ConfigReader( + +filePath: "/etc/config.json", +allowMissing: false // This is the default +) +) + +When `allowMissing` is `true`, missing files are treated as empty configuration: + +// This won't throw if config.json is missing - treats it as empty +let config = ConfigReader( + +filePath: "/etc/config.json", +allowMissing: true +) +) + +// Returns the default value if the file is missing +let port = config.int(forKey: "server.port", default: 8080) + +The same applies to other file-based providers: + +// Optional secrets directory +let secretsConfig = ConfigReader( +provider: try await DirectoryFilesProvider( +directoryPath: "/run/secrets", +allowMissing: true +) +) + +// Optional environment file +let envConfig = ConfigReader( +provider: try await EnvironmentVariablesProvider( +environmentFilePath: "/etc/app.env", +allowMissing: true +) +) + +// Optional reloading configuration +let reloadingConfig = ConfigReader( + +filePath: "/etc/dynamic-config.yaml", +allowMissing: true +) +) + +### Setting up a fallback hierarchy + +Use multiple providers together to provide a configuration hierarchy that can override values at different levels. The following example uses both an environment variable provider and a JSON provider together, with values from environment variables overriding values from the JSON file. In this example, the defaults are provided using an `InMemoryProvider`, which are only read if the environment variable or the JSON key don’t exist: + +let config = ConfigReader(providers: [\ +// First check environment variables.\ +EnvironmentVariablesProvider(),\ +// Then check the config file.\ + +// Finally, use hardcoded defaults.\ +InMemoryProvider(values: [\ +"app.name": "MyApp",\ +"server.port": 8080,\ +"logging.level": "info"\ +])\ +]) + +### Fetching a value from a remote source + +You can host dynamic configuration that your app can retrieve remotely and use either the “fetch” or “watch” access pattern. The following example uses the “fetch” access pattern to asynchronously retrieve a configuration from the remote provider: + +let myRemoteProvider = MyRemoteProvider(...) +let config = ConfigReader(provider: myRemoteProvider) + +// Makes a network call to retrieve the up-to-date value. +let samplingRatio = try await config.fetchDouble(forKey: "sampling.ratio") + +### Watching for configuration changes + +You can periodically update configuration values using a reloading provider. The following example reloads a YAML file from the filesystem every 30 seconds, and illustrates using `watchInt(forKey:isSecret:fileID:line:updatesHandler:)` to provide an async sequence of updates that you can apply. + +import Configuration +import ServiceLifecycle + +// Create a reloading YAML provider + +filePath: "/etc/app-config.yaml", +pollInterval: .seconds(30) +) +// Omitted: add `provider` to the ServiceGroup. + +let config = ConfigReader(provider: provider) + +// Watch for timeout changes and update HTTP client configuration. +// Needs to run in a separate task from the provider. +try await config.watchInt(forKey: "http.requestTimeout", default: 30) { updates in +for await timeout in updates { +print("HTTP request timeout updated: \(timeout)s") +// Update HTTP client timeout configuration in real-time +} +} + +For details on reloading providers and ServiceLifecycle integration, see Using reloading providers. + +### Prefixing configuration keys + +In most cases, the configuration key provided by the reader can be directly used by the provided, for example `http.timeout` used as the environment variable name `HTTP_TIMEOUT`. + +Sometimes you might need to transform the incoming keys in some way, before they get delivered to the provider. A common example is prefixing each key with a constant prefix, for example `myapp`, turning the key `http.timeout` to `myapp.http.timeout`. + +You can use `KeyMappingProvider` and related extensions on `ConfigProvider` to achieve that. + +The following example uses the key mapping provider to adjust an environment variable provider to look for keys with the prefix `myapp`: + +// Create a base provider for environment variables +let envProvider = EnvironmentVariablesProvider() + +// Wrap it with a key mapping provider to automatically prepend "myapp." to all keys +let prefixedProvider = envProvider.prefixKeys(with: "myapp") + +let config = ConfigReader(provider: prefixedProvider) + +// This reads from the "MYAPP_DATABASE_URL" environment variable. +let databaseURL = config.string(forKey: "database.url", default: "localhost") + +For more configuration guidance, see Adopting best practices. To understand different reader method variants, check out Choosing reader methods. + +## See Also + +### Essentials + +Configuring applications + +Provide flexible and consistent configuration for your application. + +Configuring libraries + +Provide a consistent and flexible way to configure your library. + +Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +- Example use cases +- Overview +- Reading from environment variables +- Reading from a JSON configuration file +- Reading from a directory of secret files +- Handling optional configuration files +- Setting up a fallback hierarchy +- Fetching a value from a remote source +- Watching for configuration changes +- Prefixing configuration keys +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/scoped(to:) + +#app-main) + +- Configuration +- ConfigReader +- scoped(to:) + +Instance Method + +# scoped(to:) + +Returns a scoped config reader with the specified key appended to the current prefix. + +ConfigReader.swift + +## Parameters + +`configKey` + +The key components to append to the current key prefix. + +## Return Value + +A config reader for accessing values within the specified scope. + +## Discussion + +let httpConfig = config.scoped(to: ConfigKey(["http", "client"])) +let timeout = httpConfig.int(forKey: "timeout", default: 30) // Reads "http.client.timeout" + +- scoped(to:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider + +- Configuration +- EnvironmentVariablesProvider + +Structure + +# EnvironmentVariablesProvider + +A configuration provider that sources values from environment variables. + +struct EnvironmentVariablesProvider + +EnvironmentVariablesProvider.swift + +## Mentioned in + +Troubleshooting and access reporting + +Configuring applications + +Example use cases + +## Overview + +This provider reads configuration values from environment variables, supporting both the current process environment and `.env` files. It automatically converts hierarchical configuration keys into standard environment variable naming conventions and handles type conversion for all supported configuration value types. + +## Key transformation + +Configuration keys are transformed into environment variable names using these rules: + +- Components are joined with underscores + +- All characters are converted to uppercase + +- CamelCase is detected and word boundaries are marked with underscores + +- Non-alphanumeric characters are replaced with underscores + +For example: `http.serverTimeout` becomes `HTTP_SERVER_TIMEOUT` + +## Supported data types + +The provider supports all standard configuration types: + +- Strings, integers, doubles, and booleans + +- Arrays of strings, integers, doubles, and booleans (comma-separated by default) + +- Byte arrays (base64-encoded by default) + +- Arrays of byte chunks + +## Secret handling + +Environment variables can be marked as secrets using a `SecretsSpecifier`. Secret values are automatically redacted in debug output and logging. + +## Usage + +### Reading environment variables in the current process + +// Assuming the environment contains the following variables: +// HTTP_CLIENT_USER_AGENT=Config/1.0 (Test) +// HTTP_CLIENT_TIMEOUT=15.0 +// HTTP_SECRET=s3cret +// HTTP_VERSION=2 +// ENABLED=true + +let provider = EnvironmentVariablesProvider( +secretsSpecifier: .specific(["HTTP_SECRET"]) +) +// Prints all values, redacts "HTTP_SECRET" automatically. +print(provider) +let config = ConfigReader(provider: provider) +let isEnabled = config.bool(forKey: "enabled", default: false) +let userAgent = config.string(forKey: "http.client.user-agent", default: "unspecified") +// ... + +### Reading environment variables from a \`.env\`-style file + +// Assuming the local file system has a file called `.env` in the current working directory +// with the following contents: +// +// HTTP_CLIENT_USER_AGENT=Config/1.0 (Test) +// HTTP_CLIENT_TIMEOUT=15.0 +// HTTP_SECRET=s3cret +// HTTP_VERSION=2 +// ENABLED=true + +let provider = try await EnvironmentVariablesProvider( +environmentFilePath: ".env", +secretsSpecifier: .specific(["HTTP_SECRET"]) +) +// Prints all values, redacts "HTTP_SECRET" automatically. +print(provider) +let config = ConfigReader(provider: provider) +let isEnabled = config.bool(forKey: "enabled", default: false) +let userAgent = config.string(forKey: "http.client.user-agent", default: "unspecified") +// ... + +### Config context + +The environment variables provider ignores the context passed in `context`. + +## Topics + +### Creating an environment variable provider + +Creates a new provider that reads from the current process environment. + +Creates a new provider from a custom dictionary of environment variables. + +Creates a new provider that reads from an environment file. + +### Inspecting an environment variable provider + +Returns the raw string value for a specific environment variable name. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- EnvironmentVariablesProvider +- Mentioned in +- Overview +- Key transformation +- Supported data types +- Secret handling +- Usage +- Reading environment variables in the current process +- Reading environment variables from a \`.env\`-style file +- Config context +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey + +- Configuration +- ConfigKey + +Structure + +# ConfigKey + +A configuration key representing a relative path to a configuration value. + +struct ConfigKey + +ConfigKey.swift + +## Overview + +Configuration keys consist of hierarchical string components forming paths similar to file system paths or JSON object keys. For example, `["http", "timeout"]` represents the `timeout` value nested under `http`. + +Keys support additional context information that providers can use to refine lookups or provide specialized behavior. + +## Usage + +Create keys using string literals, arrays, or the initializers: + +let key1: ConfigKey = "database.connection.timeout" +let key2 = ConfigKey(["api", "endpoints", "primary"]) +let key3 = ConfigKey("server.port", context: ["environment": .string("production")]) + +## Topics + +### Creating a configuration key + +[`init(String, context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-6vten) + +Creates a new configuration key. + +[`init([String], context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-9ifez) + +### Inspecting a configuration key + +[`var components: [String]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/components) + +The hierarchical components that make up this configuration key. + +[`var context: [String : ConfigContextValue]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/context) + +Additional context information for this configuration key. + +## Relationships + +### Conforms To + +- `Swift.Comparable` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` +- `Swift.Equatable` +- `Swift.ExpressibleByArrayLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Configuration keys + +`struct AbsoluteConfigKey` + +A configuration key that represents an absolute path to a configuration value. + +`enum ConfigContextValue` + +A value that can be stored in a configuration context. + +- ConfigKey +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider + +- Configuration +- CommandLineArgumentsProvider + +Structure + +# CommandLineArgumentsProvider + +A configuration provider that sources values from command-line arguments. + +struct CommandLineArgumentsProvider + +CommandLineArgumentsProvider.swift + +## Overview + +Reads configuration values from CLI arguments with type conversion and secrets handling. Keys are encoded to CLI flags at lookup time. + +## Package traits + +This type is guarded by the `CommandLineArgumentsSupport` package trait. + +## Key formats + +- `--key value` \- A key-value pair with separate arguments. + +- `--key=value` \- A key-value pair with an equals sign. + +- `--flag` \- A Boolean flag, treated as `true`. + +- `--key val1 val2` \- Multiple values (arrays). + +Configuration keys are transformed to CLI flags: `["http", "serverTimeout"]` → `--http-server-timeout`. + +## Array handling + +Arrays can be specified in multiple ways: + +- **Space-separated**: `--tags swift configuration cli` + +- **Repeated flags**: `--tags swift --tags configuration --tags cli` + +- **Comma-separated**: `--tags swift,configuration,cli` + +- **Mixed**: `--tags swift,configuration --tags cli` + +All formats produce the same result when accessed as an array type. + +## Usage + +// CLI: program --debug --host localhost --ports 8080 8443 +let provider = CommandLineArgumentsProvider() +let config = ConfigReader(provider: provider) + +let isDebug = config.bool(forKey: "debug", default: false) // true +let host = config.string(forKey: "host", default: "0.0.0.0") // "localhost" +let ports = config.intArray(forKey: "ports", default: []) // [8080, 8443] + +### With secrets + +let provider = CommandLineArgumentsProvider( +secretsSpecifier: .specific(["--api-key"]) +) + +### Custom arguments + +let provider = CommandLineArgumentsProvider( +arguments: ["program", "--verbose", "--timeout", "30"], +secretsSpecifier: .dynamic { key, _ in key.contains("--secret") } +) + +## Topics + +### Creating a command line arguments provider + +Creates a new CLI provider with the provided arguments. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- CommandLineArgumentsProvider +- Overview +- Package traits +- Key formats +- Array handling +- Usage +- With secrets +- Custom arguments +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/choosing-reader-methods + +- Configuration +- Choosing reader methods + +Article + +# Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +## Overview + +For every configuration access pattern (get, fetch, watch) and data type, Swift Configuration provides three method variants that handle missing or invalid values differently: + +- **Optional variant**: Returns `nil` when a value is missing or cannot be converted. + +- **Default variant**: Returns a fallback value when a value is missing or cannot be converted. + +- **Required variant**: Throws an error when a value is missing or cannot be converted. + +Understanding these variants helps you write robust configuration code that handles missing values appropriately for your use case. + +### Optional variants + +Optional variants return `nil` when a configuration value is missing or cannot be converted to the expected type. These methods have the simplest signatures and are ideal when configuration values are truly optional. + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) + +// Optional get +let timeout: Int? = config.int(forKey: "http.timeout") +let apiUrl: String? = config.string(forKey: "api.url") + +// Optional fetch +let latestTimeout: Int? = try await config.fetchInt(forKey: "http.timeout") + +// Optional watch +try await config.watchInt(forKey: "http.timeout") { updates in +for await timeout in updates { +if let timeout = timeout { +print("Timeout is set to: \(timeout)") +} else { +print("No timeout configured") +} +} +} + +#### When to use + +Use optional variants when: + +- **Truly optional features**: The configuration controls optional functionality. + +- **Gradual rollouts**: New configuration that might not be present everywhere. + +- **Conditional behavior**: Your code can operate differently based on presence or absence. + +- **Debugging and diagnostics**: You want to detect missing configuration explicitly. + +#### Error handling behavior + +Optional variants handle errors gracefully by returning `nil`: + +- Missing values return `nil`. + +- Type conversion errors return `nil`. + +- Provider errors return `nil` (except for fetch variants, which always propagate provider errors). + +// These all return nil instead of throwing +let missingPort = config.int(forKey: "nonexistent.port") // nil +let invalidPort = config.int(forKey: "invalid.port.value") // nil (if value can't convert to Int) +let failingPort = config.int(forKey: "provider.error.key") // nil (if provider fails) + +// Fetch variants still throw provider errors +do { +let port = try await config.fetchInt(forKey: "network.error") // Throws provider error +} catch { +// Handle network or provider errors +} + +### Default variants + +Default variants return a specified fallback value when a configuration value is missing or cannot be converted. These provide guaranteed non-optional results while handling missing configuration gracefully. + +// Default get +let timeout = config.int(forKey: "http.timeout", default: 30) +let retryCount = config.int(forKey: "network.retries", default: 3) + +// Default fetch +let latestTimeout = try await config.fetchInt(forKey: "http.timeout", default: 30) + +// Default watch +try await config.watchInt(forKey: "http.timeout", default: 30) { updates in +for await timeout in updates { +print("Using timeout: \(timeout)") // Always has a value +connectionManager.setTimeout(timeout) +} +} + +#### When to use + +Use default variants when: + +- **Sensible defaults exist**: You have reasonable fallback values for missing configuration. + +- **Simplified code flow**: You want to avoid optional handling in business logic. + +- **Required functionality**: The feature needs a value to operate, but can use defaults. + +- **Configuration evolution**: New settings that should work with older deployments. + +#### Choosing good defaults + +Consider these principles when choosing default values: + +// Safe defaults that won't cause issues +let timeout = config.int(forKey: "http.timeout", default: 30) // Reasonable timeout +let maxRetries = config.int(forKey: "retries.max", default: 3) // Conservative retry count +let cacheSize = config.int(forKey: "cache.size", default: 1000) // Modest cache size + +// Environment-specific defaults +let logLevel = config.string(forKey: "log.level", default: "info") // Safe default level +let enableDebug = config.bool(forKey: "debug.enabled", default: false) // Secure default + +// Performance defaults that err on the side of caution +let batchSize = config.int(forKey: "batch.size", default: 100) // Small safe batch +let maxConnections = config.int(forKey: "pool.max", default: 10) // Conservative pool + +#### Error handling behavior + +Default variants handle errors by returning the default value: + +- Missing values return the default. + +- Type conversion errors return the default. + +- Provider errors return the default (except for fetch variants). + +### Required variants + +Required variants throw errors when configuration values are missing or cannot be converted. These enforce that critical configuration must be present and valid. + +do { +// Required get +let serverPort = try config.requiredInt(forKey: "server.port") +let databaseHost = try config.requiredString(forKey: "database.host") + +// Required fetch +let latestPort = try await config.fetchRequiredInt(forKey: "server.port") + +// Required watch +try await config.watchRequiredInt(forKey: "server.port") { updates in +for try await port in updates { +print("Server port updated to: \(port)") +server.updatePort(port) +} +} +} catch { +fatalError("Configuration error: \(error)") +} + +#### When to use + +Use required variants when: + +- **Essential service configuration**: Server ports, database hosts, service endpoints. + +- **Application startup**: Values needed before the application can function properly. + +- **Critical functionality**: Configuration that must be present for core features to work. + +- **Fail-fast behavior**: You want immediate errors for missing critical configuration. + +### Choosing the right variant + +Use this decision tree to select the appropriate variant: + +#### Is the configuration value critical for application operation? + +**Yes** → Use **required variants** + +// Critical values that must be present +let serverPort = try config.requiredInt(forKey: "server.port") +let databaseHost = try config.requiredString(forKey: "database.host") + +**No** → Continue to next question + +#### Do you have a reasonable default value? + +**Yes** → Use **default variants** + +// Optional features with sensible defaults +let timeout = config.int(forKey: "http.timeout", default: 30) +let retryCount = config.int(forKey: "retries", default: 3) + +**No** → Use **optional variants** + +// Truly optional features where absence is meaningful +let debugEndpoint = config.string(forKey: "debug.endpoint") +let customTheme = config.string(forKey: "ui.theme") + +### Context and type conversion + +All variants support the same additional features: + +#### Configuration context + +// Optional with context +let timeout = config.int( +forKey: ConfigKey( +"service.timeout", +context: ["environment": "production", "region": "us-east-1"] +) +) + +// Default with context +let timeout = config.int( +forKey: ConfigKey( +"service.timeout", +context: ["environment": "production"] +), +default: 30 +) + +// Required with context +let timeout = try config.requiredInt( +forKey: ConfigKey( +"service.timeout", +context: ["environment": "production"] +) +) + +#### Type conversion + +String configuration values can be automatically converted to other types using the `as:` parameter. This works with: + +**Built-in convertible types:** + +- `SystemPackage.FilePath`: Converts from file paths. + +- `Foundation.URL`: Converts from URL strings. + +- `Foundation.UUID`: Converts from UUID strings. + +- `Foundation.Date`: Converts from ISO8601 date strings. + +**String-backed enums:** + +**Custom types:** + +- Types that you explicitly conform to `ExpressibleByConfigString`. + +// Built-in type conversion +let apiUrl = config.string(forKey: "api.url", as: URL.self) +let requestId = config.string(forKey: "request.id", as: UUID.self) +let configPath = config.string(forKey: "config.path", as: FilePath.self) +let startDate = config.string(forKey: "launch.date", as: Date.self) + +enum LogLevel: String { +case debug, info, warning, error +} + +// Optional conversion +let level: LogLevel? = config.string(forKey: "log.level", as: LogLevel.self) + +// Default conversion +let level = config.string(forKey: "log.level", as: LogLevel.self, default: .info) + +// Required conversion +let level = try config.requiredString(forKey: "log.level", as: LogLevel.self) + +// Custom type conversion (ExpressibleByConfigString) +struct DatabaseURL: ExpressibleByConfigString { +let url: URL + +init?(configString: String) { +guard let url = URL(string: configString) else { return nil } +self.url = url +} + +var description: String { url.absoluteString } +} +let dbUrl = config.string(forKey: "database.url", as: DatabaseURL.self) + +#### Secret handling + +// Mark sensitive values as secrets in all variants +let optionalKey = config.string(forKey: "api.key", isSecret: true) +let defaultKey = config.string(forKey: "api.key", isSecret: true, default: "development-key") +let requiredKey = try config.requiredString(forKey: "api.key", isSecret: true) + +Also check out Handling secrets correctly. + +### Best practices + +1. **Use required variants** only for truly critical configuration. + +2. **Use default variants** for user experience settings where missing configuration shouldn’t break functionality. + +3. **Use optional variants** for feature flags and debugging where the absence of configuration is meaningful. + +4. **Choose safe defaults** that won’t cause security issues or performance problems if used in production. + +For guidance on selecting between get, fetch, and watch access patterns, see Choosing the access pattern. For more configuration guidance, check out Adopting best practices. + +## See Also + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +- Choosing reader methods +- Overview +- Optional variants +- Default variants +- Required variants +- Choosing the right variant +- Context and type conversion +- Best practices +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider + +- Configuration +- KeyMappingProvider + +Structure + +# KeyMappingProvider + +A configuration provider that maps all keys before delegating to an upstream provider. + +KeyMappingProvider.swift + +## Mentioned in + +Example use cases + +## Overview + +Use `KeyMappingProvider` to automatically apply a mapping function to every configuration key before passing it to an underlying provider. This is particularly useful when the upstream source of configuration keys differs from your own. Another example is namespacing configuration values from specific sources, such as prefixing environment variables with an application name while leaving other configuration sources unchanged. + +### Common use cases + +Use `KeyMappingProvider` for: + +- Rewriting configuration keys to match upstream configuration sources. + +- Legacy system integration that adapts existing sources with different naming conventions. + +## Example + +Use `KeyMappingProvider` when you want to map keys for specific providers in a multi-provider setup: + +// Create providers +let envProvider = EnvironmentVariablesProvider() + +// Only remap the environment variables, not the JSON config +let keyMappedEnvProvider = KeyMappingProvider(upstream: envProvider) { key in +key.prepending(["myapp", "prod"]) +} + +let config = ConfigReader(providers: [\ +keyMappedEnvProvider, // Reads from "MYAPP_PROD_*" environment variables\ +jsonProvider // Reads from JSON without prefix\ +]) + +// This reads from "MYAPP_PROD_DATABASE_HOST" env var or "database.host" in JSON +let host = config.string(forKey: "database.host", default: "localhost") + +## Convenience method + +You can also use the `prefixKeys(with:)` convenience method on configuration provider types to wrap one in a `KeyMappingProvider`: + +let envProvider = EnvironmentVariablesProvider() +let keyMappedEnvProvider = envProvider.mapKeys { key in +key.prepending(["myapp", "prod"]) +} + +## Topics + +### Creating a key-mapping provider + +Creates a new provider. + +## Relationships + +### Conforms To + +- `ConfigProvider` +Conforms when `Upstream` conforms to `ConfigProvider`. + +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +- KeyMappingProvider +- Mentioned in +- Overview +- Common use cases +- Example +- Convenience method +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/choosing-access-patterns + +- Configuration +- Choosing the access pattern + +Article + +# Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +## Overview + +Swift Configuration provides three access patterns for retrieving configuration values, each optimized for different use cases and performance requirements. + +The three access patterns are: + +- **Get**: Synchronous access to current values available locally, in-memory. + +- **Fetch**: Asynchronous access to retrieve fresh values from authoritative sources, optionally with extra context. + +- **Watch**: Reactive access that provides real-time updates when values change. + +### Get: Synchronous local access + +The “get” pattern provides immediate, synchronous access to configuration values that are already available in memory. This is the fastest and most commonly used access pattern. + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) + +// Get the current timeout value synchronously +let timeout = config.int(forKey: "http.timeout", default: 30) + +// Get a required value that must be present +let apiKey = try config.requiredString(forKey: "api.key", isSecret: true) + +#### When to use + +Use the “get” pattern when: + +- **Performance is critical**: You need immediate access without async overhead. + +- **Values are stable**: Configuration doesn’t change frequently during runtime. + +- **Simple providers**: Using environment variables, command-line arguments, or files. + +- **Startup configuration**: Reading values during application initialization. + +- **Request handling**: Accessing configuration in hot code paths where async calls would add latency. + +#### Behavior characteristics + +- Returns the currently cached value from the provider. + +- No network or I/O operations occur during the call. + +- Values may become stale if the underlying data source changes and the provider is either non-reloading, or has a long reload interval. + +### Fetch: Asynchronous fresh access + +The “fetch” pattern asynchronously retrieves the most current value from the authoritative data source, ensuring you always get up-to-date configuration. + +let config = ConfigReader(provider: remoteConfigProvider) + +// Fetch the latest timeout from a remote configuration service +let timeout = try await config.fetchInt(forKey: "http.timeout", default: 30) + +// Fetch with context for environment-specific configuration +let dbConnectionString = try await config.fetchRequiredString( +forKey: ConfigKey( +"database.url", +context: [\ +"environment": "production",\ +"region": "us-west-2",\ +"service": "user-service"\ +] +), +isSecret: true +) + +#### When to use + +Use the “fetch” pattern when: + +- **Freshness is critical**: You need the latest configuration values. + +- **Remote providers**: Using configuration services, databases, or external APIs that perform evaluation remotely. + +- **Infrequent access**: Reading configuration occasionally, not in hot paths. + +- **Setup operations**: Configuring long-lived resources like database connections where one-time overhead isn’t a concern, and the improved freshness is important. + +- **Administrative operations**: Fetching current settings for management interfaces. + +#### Behavior characteristics + +- Always contacts the authoritative data source. + +- May involve network calls, file system access, or database queries. + +- Providers may (but are not required to) cache the fetched value for subsequent “get” calls. + +- Throws an error if the provider fails to reach the source. + +### Watch: Reactive continuous updates + +The “watch” pattern provides an async sequence of configuration updates, allowing you to react to changes in real-time. This is ideal for long-running services that need to adapt to configuration changes without restarting. + +The async sequence is required to receive the current value as the first element as quickly as possible - this is part of the API contract with configuration providers (for details, check out `ConfigProvider`.) + +let config = ConfigReader(provider: reloadingProvider) + +// Watch for timeout changes and update connection pools +try await config.watchInt(forKey: "http.timeout", default: 30) { updates in +for await newTimeout in updates { +print("HTTP timeout updated to: \(newTimeout)") +connectionPool.updateTimeout(newTimeout) +} +} + +#### When to use + +Use the “watch” pattern when: + +- **Dynamic configuration**: Values change during application runtime. + +- **Hot reloading**: You need to update behavior without restarting the service. + +- **Feature toggles**: Enabling or disabling features based on configuration changes. + +- **Resource management**: Adjusting timeouts, limits, or thresholds dynamically. + +- **A/B testing**: Updating experimental parameters in real-time. + +#### Behavior characteristics + +- Immediately emits the initial value, then subsequent updates. + +- Continues monitoring until the task is cancelled. + +- Works with providers like `ReloadingFileProvider`. + +For details on reloading providers, check out Using reloading providers. + +### Using configuration context + +All access patterns support configuration context, which provides additional metadata to help providers return more specific values. Context is particularly useful with the “fetch” and “watch” patterns when working with dynamic or environment-aware providers. + +#### Filtering watch updates using context + +let context: [String: ConfigContextValue] = [\ +"environment": "production",\ +"region": "us-east-1",\ +"service_version": "2.1.0",\ +"feature_tier": "premium",\ +"load_factor": 0.85\ +] + +// Get environment-specific database configuration +let dbConfig = try await config.fetchRequiredString( +forKey: ConfigKey( +"database.connection_string", +context: context +), +isSecret: true +) + +// Watch for region-specific timeout adjustments +try await config.watchInt( +forKey: ConfigKey( +"api.timeout", +context: ["region": "us-west-2"] +), +default: 5000 +) { updates in +for await timeout in updates { +apiClient.updateTimeout(milliseconds: timeout) +} +} + +#### Get pattern performance + +- **Fastest**: No async overhead, immediate return. + +- **Memory usage**: Minimal, uses cached values. + +- **Best for**: Request handling, hot code paths, startup configuration. + +#### Fetch pattern performance + +- **Moderate**: Async overhead plus data source access time. + +- **Network dependent**: Performance varies with provider implementation. + +- **Best for**: Infrequent access, setup operations, administrative tasks. + +#### Watch pattern performance + +- **Background monitoring**: Continuous resource usage for monitoring. + +- **Event-driven**: Efficient updates only when values change. + +- **Best for**: Long-running services, dynamic configuration, feature toggles. + +### Error handling strategies + +Each access pattern handles errors differently: + +#### Get pattern errors + +// Returns nil or default value for missing/invalid config +let timeout = config.int(forKey: "http.timeout", default: 30) + +// Required variants throw errors for missing values +do { +let apiKey = try config.requiredString(forKey: "api.key") +} catch { +// Handle missing required configuration +} + +#### Fetch pattern errors + +// All fetch methods propagate provider and conversion errors +do { +let config = try await config.fetchRequiredString(forKey: "database.url") +} catch { +// Handle network errors, missing values, or conversion failures +} + +#### Watch pattern errors + +// Errors appear in the async sequence +try await config.watchRequiredInt(forKey: "port") { updates in +do { +for try await port in updates { +server.updatePort(port) +} +} catch { +// Handle provider errors or missing required values +} +} + +### Best practices + +1. **Choose based on use case**: Use “get” for performance-critical paths, “fetch” for freshness, and “watch” for hot reloading. + +2. **Handle errors appropriately**: Design error handling strategies that match your application’s resilience requirements. + +3. **Use context judiciously**: Provide context when you need environment-specific or conditional configuration values. + +4. **Monitor configuration access**: Use `AccessReporter` to understand your application’s configuration dependencies. + +5. **Cache wisely**: For frequently accessed values, prefer “get” over repeated “fetch” calls. + +For more guidance on selecting the right reader methods for your needs, see Choosing reader methods. To learn about handling sensitive configuration values securely, check out Handling secrets correctly. If you encounter issues with configuration access, refer to Troubleshooting and access reporting for debugging techniques. + +## See Also + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +- Choosing the access pattern +- Overview +- Get: Synchronous local access +- Fetch: Asynchronous fresh access +- Watch: Reactive continuous updates +- Using configuration context +- Summary of performance considerations +- Error handling strategies +- Best practices +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessreporter + +- Configuration +- AccessReporter + +Protocol + +# AccessReporter + +A type that receives and processes configuration access events. + +protocol AccessReporter : Sendable + +AccessReporter.swift + +## Mentioned in + +Troubleshooting and access reporting + +Choosing the access pattern + +Configuring libraries + +## Overview + +Access reporters track when configuration values are read, fetched, or watched, to provide visibility into configuration usage patterns. This is useful for debugging, auditing, and understanding configuration dependencies. + +## Topics + +### Required methods + +`func report(AccessEvent)` + +Processes a configuration access event. + +**Required** + +## Relationships + +### Inherits From + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Conforming Types + +- `AccessLogger` +- `BroadcastingAccessReporter` +- `FileAccessLogger` + +## See Also + +### Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +- AccessReporter +- Mentioned in +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/using-reloading-providers + +- Configuration +- Using reloading providers + +Article + +# Using reloading providers + +Automatically reload configuration from files when they change. + +## Overview + +A reloading provider monitors configuration files for changes and automatically updates your application’s configuration without requiring restarts. Swift Configuration provides: + +- `ReloadingFileProvider` with `JSONSnapshot` for JSON configuration files. + +- `ReloadingFileProvider` with `YAMLSnapshot` for YAML configuration files. + +#### Creating and running providers + +Reloading providers run in a `ServiceGroup`: + +import ServiceLifecycle + +filePath: "/etc/config.json", +allowMissing: true, // Optional: treat missing file as empty config +pollInterval: .seconds(15) +) + +let serviceGroup = ServiceGroup( +services: [provider], +logger: logger +) + +try await serviceGroup.run() + +#### Reading configuration + +Use a reloading provider in the same fashion as a static provider, pass it to a `ConfigReader`: + +let config = ConfigReader(provider: provider) +let host = config.string( +forKey: "database.host", +default: "localhost" +) + +#### Poll interval considerations + +Choose poll intervals based on how quickly you need to detect changes: + +// Development: Quick feedback +pollInterval: .seconds(1) + +// Production: Balanced performance (default) +pollInterval: .seconds(15) + +// Batch processing: Resource efficient +pollInterval: .seconds(300) + +### Watching for changes + +The following sections provide examples of watching for changes in configuration from a reloading provider. + +#### Individual values + +The example below watches for updates in a single key, `database.host`: + +try await config.watchString( +forKey: "database.host" +) { updates in +for await host in updates { +print("Database host updated: \(host)") +} +} + +#### Configuration snapshots + +The following example reads the `database.host` and `database.password` key with the guarantee that they are read from the same update of the reloading file: + +try await config.watchSnapshot { updates in +for await snapshot in updates { +let host = snapshot.string(forKey: "database.host") +let password = snapshot.string(forKey: "database.password", isSecret: true) +print("Configuration updated - Database: \(host)") +} +} + +### Comparison with static providers + +| Feature | Static providers | Reloading providers | +| --- | --- | --- | +| **File reading** | Load once at startup | Reloading on change | +| **Service lifecycle** | Not required | Conforms to `Service` and must run in a `ServiceGroup` | +| **Configuration updates** | Require restart | Automatic reload | + +### Handling missing files during reloading + +Reloading providers support the `allowMissing` parameter to handle cases where configuration files might be temporarily missing or optional. This is useful for: + +- Optional configuration files that might not exist in all environments. + +- Configuration files that are created or removed dynamically. + +- Graceful handling of file system issues during service startup. + +#### Missing file behavior + +When `allowMissing` is `false` (the default), missing files cause errors: + +filePath: "/etc/config.json", +allowMissing: false // Default: throw error if file is missing +) +// Will throw an error if config.json doesn't exist + +When `allowMissing` is `true`, missing files are treated as empty configuration: + +filePath: "/etc/config.json", +allowMissing: true // Treat missing file as empty config +) +// Won't throw if config.json is missing - uses empty config instead + +#### Behavior during reloading + +If a file becomes missing after the provider starts, the behavior depends on the `allowMissing` setting: + +- **`allowMissing: false`**: The provider keeps the last known configuration and logs an error. + +- **`allowMissing: true`**: The provider switches to empty configuration. + +In both cases, when a valid file comes back, the provider will load it and recover. + +// Example: File gets deleted during runtime +try await config.watchString(forKey: "database.host", default: "localhost") { updates in +for await host in updates { +// With allowMissing: true, this will receive "localhost" when file is removed +// With allowMissing: false, this keeps the last known value +print("Database host: \(host)") +} +} + +#### Configuration-driven setup + +The following example sets up an environment variable provider to select the path and interval to watch for a JSON file that contains the configuration for your app: + +let envProvider = EnvironmentVariablesProvider() +let envConfig = ConfigReader(provider: envProvider) + +config: envConfig.scoped(to: "json") +// Reads JSON_FILE_PATH and JSON_POLL_INTERVAL_SECONDS +) + +### Migration from static providers + +1. **Replace initialization**: + +// Before + +// After + +2. **Add the provider to a ServiceGroup**: + +let serviceGroup = ServiceGroup(services: [provider], logger: logger) +try await serviceGroup.run() + +3. **Use ConfigReader**: + +let config = ConfigReader(provider: provider) + +// Live updates. +try await config.watchDouble(forKey: "timeout") { updates in +// Handle changes +} + +// On-demand reads - returns the current value, so might change over time. +let timeout = config.double(forKey: "timeout", default: 60.0) + +For guidance on choosing between get, fetch, and watch access patterns with reloading providers, see Choosing the access pattern. For troubleshooting reloading provider issues, check out Troubleshooting and access reporting. To learn about in-memory providers as an alternative, see Using in-memory providers. + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- Using reloading providers +- Overview +- Basic usage +- Watching for changes +- Comparison with static providers +- Handling missing files during reloading +- Advanced features +- Migration from static providers +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider + +- Configuration +- MutableInMemoryProvider + +Class + +# MutableInMemoryProvider + +A configuration provider that stores mutable values in memory. + +final class MutableInMemoryProvider + +MutableInMemoryProvider.swift + +## Mentioned in + +Using in-memory providers + +## Overview + +Unlike `InMemoryProvider`, this provider allows configuration values to be modified after initialization. It maintains thread-safe access to values and supports real-time notifications when values change, making it ideal for dynamic configuration scenarios. + +## Change notifications + +The provider supports watching for configuration changes through the standard `ConfigProvider` watching methods. When a value changes, all active watchers are automatically notified with the new value. + +## Use cases + +The mutable in-memory provider is particularly useful for: + +- **Dynamic configuration**: Values that change during application runtime + +- **Configuration bridges**: Adapting external configuration systems that push updates + +- **Testing scenarios**: Simulating configuration changes in unit tests + +- **Feature flags**: Runtime toggles that can be modified programmatically + +## Performance characteristics + +This provider offers O(1) lookup time with minimal synchronization overhead. Value updates are atomic and efficiently notify only the relevant watchers. + +## Usage + +// Create provider with initial values +let provider = MutableInMemoryProvider(initialValues: [\ +"feature.enabled": true,\ +"api.timeout": 30.0,\ +"database.host": "localhost"\ +]) + +let config = ConfigReader(provider: provider) + +// Read initial values +let isEnabled = config.bool(forKey: "feature.enabled") // true + +// Update values dynamically +provider.setValue(false, forKey: "feature.enabled") + +// Read updated values +let stillEnabled = config.bool(forKey: "feature.enabled") // false + +To learn more about the in-memory providers, check out Using in-memory providers. + +## Topics + +### Creating a mutable in-memory provider + +[`init(name: String?, initialValues: [AbsoluteConfigKey : ConfigValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/init(name:initialvalues:)) + +Creates a new mutable in-memory provider with the specified initial values. + +### Updating values in a mutable in-memory provider + +`func setValue(ConfigValue?, forKey: AbsoluteConfigKey)` + +Updates the stored value for the specified configuration key. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- MutableInMemoryProvider +- Mentioned in +- Overview +- Change notifications +- Use cases +- Performance characteristics +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/development + +- Configuration +- Developing Swift Configuration + +Article + +# Developing Swift Configuration + +Learn about tools and conventions used to develop the Swift Configuration package. + +## Overview + +The Swift Configuration package is developed using modern Swift development practices and tools. This guide covers the development workflow, code organization, and tooling used to maintain the package. + +### Process + +We follow an open process and discuss development on GitHub issues, pull requests, and on the Swift Forums. Details on how to submit an issue or a pull requests can be found in CONTRIBUTING.md. + +Large features and changes go through a lightweight proposals process - to learn more, check out Proposals. + +#### Package organization + +The package contains several Swift targets organized by functionality: + +- **Configuration** \- Core configuration reading APIs and built-in providers. + +- **ConfigurationTesting** \- Testing utilities for external configuration providers. + +- **ConfigurationTestingInternal** \- Internal testing utilities and helpers. + +#### Running CI checks locally + +You can run the Github Actions workflows locally using act. To run all the jobs that run on a pull request, use the following command: + +% act pull_request +% act workflow_call -j soundness --input shell_check_enabled=true + +To bind-mount the working directory to the container, rather than a copy, use `--bind`. For example, to run just the formatting, and have the results reflected in your working directory: + +% act --bind workflow_call -j soundness --input format_check_enabled=true + +If you’d like `act` to always run with certain flags, these can be be placed in an `.actrc` file either in the current working directory or your home directory, for example: + +--container-architecture=linux/amd64 +--remote-name upstream +--action-offline-mode + +#### Code generation with gyb + +This package uses the “generate your boilerplate” (gyb) script from the Swift repository to stamp out repetitive code for each supported primitive type. + +The files that include gyb syntax end with `.gyb`, and after making changes to any of those files, run: + +./Scripts/generate_boilerplate_files_with_gyb.sh + +If you’re adding a new `.gyb` file, also make sure to add it to the exclude list in `Package.swift`. + +After running this script, also run the formatter before opening a PR. + +#### Code formatting + +The project uses swift-format for consistent code style. You can run CI checks locally using `act`. + +To run formatting checks: + +act --bind workflow_call -j soundness --input format_check_enabled=true + +#### Testing + +The package includes comprehensive test suites for all components: + +- Unit tests for individual providers and utilities. + +- Compatibility tests using `ProviderCompatTest` for built-in providers. + +Run tests using Swift Package Manager: + +swift test --enable-all-traits + +#### Documentation + +Documentation is written using DocC and includes: + +- API reference documentation in source code. + +- Conceptual guides in `.docc` catalogs. + +- Usage examples and best practices. + +- Troubleshooting guides. + +Preview documentation locally: + +SWIFT_PREVIEW_DOCS=1 swift package --disable-sandbox preview-documentation --target Configuration + +#### Code style + +- Follow Swift API Design Guidelines. + +- Use meaningful names for types, methods, and variables. + +- Include comprehensive documentation for all APIs, not only public types. + +- Write unit tests for new functionality. + +#### Provider development + +When developing new configuration providers: + +1. Implement the `ConfigProvider` protocol. + +2. Add comprehensive unit tests. + +3. Run compatibility tests using `ProviderCompatTest`. + +4. Add documentation to all symbols, not just `public`. + +#### Documentation requirements + +All APIs must include: + +- Clear, concise documentation comments. + +- Usage examples where appropriate. + +- Parameter and return value descriptions. + +- Error conditions and handling. + +## See Also + +### Contributing + +Collaborate on API changes to Swift Configuration by writing a proposal. + +- Developing Swift Configuration +- Overview +- Process +- Repository structure +- Development tools +- Contributing guidelines +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/troubleshooting + +- Configuration +- Troubleshooting and access reporting + +Article + +# Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +## Overview + +### Debugging configuration issues + +If your configuration values aren’t being read correctly, check: + +1. **Environment variable naming**: When using `EnvironmentVariablesProvider`, keys are automatically converted to uppercase with dots replaced by underscores. For example, `database.url` becomes `DATABASE_URL`. + +2. **Provider ordering**: When using multiple providers, they’re checked in order and the first one that returns a value wins. + +3. **Debug with an access reporter**: Use access reporting to see which keys are being queried and what values (if any) are being returned. See the next section for details. + +For guidance on selecting the right configuration access patterns and reader methods, check out Choosing the access pattern and Choosing reader methods. + +### Access reporting + +Configuration access reporting can help you debug issues and understand which configuration values your application is using. Swift Configuration provides two built-in ways to log access ( `AccessLogger` and `FileAccessLogger`), and you can also implement your own `AccessReporter`. + +#### Using AccessLogger + +`AccessLogger` integrates with Swift Log and records all configuration accesses: + +let logger = Logger(label: "...") +let accessLogger = AccessLogger(logger: logger) +let config = ConfigReader(provider: provider, accessReporter: accessLogger) + +// Each access will now be logged. +let timeout = config.double(forKey: "http.timeout", default: 30.0) + +This produces log entries showing: + +- Which configuration keys were accessed. + +- What values were returned (with secret values redacted). + +- Which provider supplied the value. + +- Whether default values were used. + +- The location of the code reading the config value. + +- The timestamp of the access. + +#### Using FileAccessLogger + +For writing access events to a file, especially useful during ad-hoc debugging, use `FileAccessLogger`: + +let fileLogger = try FileAccessLogger(filePath: "/var/log/myapp/config-access.log") +let config = ConfigReader(provider: provider, accessReporter: fileLogger) + +You can also enable file access logging for the whole application, without recompiling your code, by setting an environment variable: + +export CONFIG_ACCESS_LOG_FILE=/var/log/myapp/config-access.log + +And then read from the file to see one line per config access: + +tail -f /var/log/myapp/config-access.log + +#### Provider errors + +If any provider throws an error during lookup: + +- **Required methods** (`requiredString`, etc.): Error is immediately thrown to the caller. + +- **Optional methods** (with or without defaults): Error is handled gracefully; `nil` or the default value is returned. + +#### Missing values + +When no provider has the requested value: + +- **Methods with defaults**: Return the provided default value. + +- **Methods without defaults**: Return `nil`. + +- **Required methods**: Throw an error. + +#### File not found errors + +File-based providers ( `FileProvider`, `ReloadingFileProvider`, `DirectoryFilesProvider`, `EnvironmentVariablesProvider` with file path) can throw “file not found” errors when expected configuration files don’t exist. + +Common scenarios and solutions: + +**Optional configuration files:** + +// Problem: App crashes when optional config file is missing + +// Solution: Use allowMissing parameter + +filePath: "/etc/optional-config.json", +allowMissing: true +) + +**Environment-specific files:** + +// Different environments may have different config files +let configPath = "/etc/\(environment)/config.json" + +filePath: configPath, +allowMissing: true // Gracefully handle missing env-specific configs +) + +**Container startup issues:** + +// Config files might not be ready when container starts + +filePath: "/mnt/config/app.json", +allowMissing: true // Allow startup with empty config, load when available +) + +#### Configuration not updating + +If your reloading provider isn’t detecting file changes: + +1. **Check ServiceGroup**: Ensure the provider is running in a `ServiceGroup`. + +2. **Enable verbose logging**: The built-in providers use Swift Log for detailed logging, which can help spot issues. + +3. **Verify file path**: Confirm the file path is correct, the file exists, and file permissions are correct. + +4. **Check poll interval**: Consider if your poll interval is appropriate for your use case. + +#### ServiceGroup integration issues + +Common ServiceGroup problems: + +// Incorrect: Provider not included in ServiceGroup + +let config = ConfigReader(provider: provider) +// File monitoring won't work + +// Correct: Provider runs in ServiceGroup + +let serviceGroup = ServiceGroup(services: [provider], logger: logger) +try await serviceGroup.run() + +For more details about reloading providers and ServiceLifecycle integration, see Using reloading providers. To learn about proper configuration practices that can prevent common issues, check out Adopting best practices. + +## See Also + +### Troubleshooting and access reporting + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +- Troubleshooting and access reporting +- Overview +- Debugging configuration issues +- Access reporting +- Error handling +- Reloading provider troubleshooting +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileparsingoptions + +- Configuration +- FileParsingOptions + +Protocol + +# FileParsingOptions + +A type that provides parsing options for file configuration snapshots. + +protocol FileParsingOptions : Sendable + +FileProviderSnapshot.swift + +## Overview + +This protocol defines the requirements for parsing options types used with `FileConfigSnapshot` implementations. Types conforming to this protocol provide configuration parameters that control how file data is interpreted and parsed during snapshot creation. + +The parsing options are passed to the `init(data:providerName:parsingOptions:)` initializer, allowing custom file format implementations to access format-specific parsing settings such as character encoding, date formats, or validation rules. + +## Usage + +Implement this protocol to provide parsing options for your custom `FileConfigSnapshot`: + +struct MyParsingOptions: FileParsingOptions { +let encoding: String.Encoding +let dateFormat: String? +let strictValidation: Bool + +static let `default` = MyParsingOptions( +encoding: .utf8, +dateFormat: nil, +strictValidation: false +) +} + +struct MyFormatSnapshot: FileConfigSnapshot { +typealias ParsingOptions = MyParsingOptions + +init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws { +// Implementation that inspects `parsingOptions` properties like `encoding`, +// `dateFormat`, and `strictValidation`. +} +} + +## Topics + +### Required properties + +``static var `default`: Self`` + +The default instance of this options type. + +**Required** + +### Parsing options + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +## Relationships + +### Inherits From + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Conforming Types + +- `JSONSnapshot.ParsingOptions` +- `YAMLSnapshot.ParsingOptions` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- FileParsingOptions +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshot + +- Configuration +- ConfigSnapshot + +Protocol + +# ConfigSnapshot + +An immutable snapshot of a configuration provider’s state. + +protocol ConfigSnapshot : Sendable + +ConfigProvider.swift + +## Overview + +Snapshots enable consistent reads of multiple related configuration keys by capturing the provider’s state at a specific moment. This prevents the underlying data from changing between individual key lookups. + +## Topics + +### Required methods + +`var providerName: String` + +The human-readable name of the configuration provider that created this snapshot. + +**Required** + +Returns a value for the specified key from this immutable snapshot. + +## Relationships + +### Inherits From + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Inherited By + +- `FileConfigSnapshot` + +### Conforming Types + +- `JSONSnapshot` +- `YAMLSnapshot` + +## See Also + +### Creating a custom provider + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- ConfigSnapshot +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configuring-applications + +- Configuration +- Configuring applications + +Article + +# Configuring applications + +Provide flexible and consistent configuration for your application. + +## Overview + +Swift Configuration provides consistent configuration for your tools and applications. This guide shows how to: + +1. Set up a configuration hierarchy with multiple providers. + +2. Configure your application’s components. + +3. Access configuration values in your application and libraries. + +4. Monitor configuration access with access reporting. + +This pattern works well for server applications where configuration comes from environment variables, configuration files, and remote services. + +### Setting up a configuration hierarchy + +Start by creating a configuration hierarchy in your application’s entry point. This defines the order in which configuration sources are consulted when looking for values: + +import Configuration +import Logging + +// Create a logger. +let logger: Logger = ... + +// Set up the configuration hierarchy: +// - environment variables first, +// - then JSON file, +// - then in-memory defaults. +// Also emit log accesses into the provider logger, +// with secrets automatically redacted. + +let config = ConfigReader( +providers: [\ +EnvironmentVariablesProvider(),\ + +filePath: "/etc/myapp/config.json",\ +allowMissing: true // Optional: treat missing file as empty config\ +),\ +InMemoryProvider(values: [\ +"http.server.port": 8080,\ +"http.server.host": "127.0.0.1",\ +"http.client.timeout": 30.0\ +])\ +], +accessReporter: AccessLogger(logger: logger) +) + +// Start your application with the config. +try await runApplication(config: config, logger: logger) + +This configuration hierarchy gives priority to environment variables, then falls + +Next, configure your application using the configuration reader: + +func runApplication( +config: ConfigReader, +logger: Logger +) async throws { +// Get server configuration. +let serverHost = config.string( +forKey: "http.server.host", +default: "localhost" +) +let serverPort = config.int( +forKey: "http.server.port", +default: 8080 +) + +// Read library configuration with a scoped reader +// with the prefix `http.client`. +let httpClientConfig = HTTPClientConfiguration( +config: config.scoped(to: "http.client") +) +let httpClient = HTTPClient(configuration: httpClientConfig) + +// Run your server with the configured components +try await startHTTPServer( +host: serverHost, +port: serverPort, +httpClient: httpClient, +logger: logger +) +} + +Finally, you configure your application across the three sources. A fully configured set of environment variables could look like the following: + +export HTTP_SERVER_HOST=localhost +export HTTP_SERVER_PORT=8080 +export HTTP_CLIENT_TIMEOUT=30.0 +export HTTP_CLIENT_MAX_CONCURRENT_CONNECTIONS=20 +export HTTP_CLIENT_BASE_URL="https://example.com" +export HTTP_CLIENT_DEBUG_LOGGING=true + +In JSON: + +{ +"http": { +"server": { +"host": "localhost", +"port": 8080 +}, +"client": { +"timeout": 30.0, +"maxConcurrentConnections": 20, +"baseURL": "https://example.com", +"debugLogging": true +} +} +} + +And using `InMemoryProvider`: + +[\ +"http.server.port": 8080,\ +"http.server.host": "127.0.0.1",\ +"http.client.timeout": 30.0,\ +"http.client.maxConcurrentConnections": 20,\ +"http.client.baseURL": "https://example.com",\ +"http.client.debugLogging": true,\ +] + +In practice, you’d only specify a subset of the config keys in each location, to match the needs of your service’s operators. + +### Using scoped configuration + +For services with multiple instances of the same component, but with different settings, use scoped configuration: + +// For our server example, we might have different API clients +// that need different settings: + +let adminConfig = config.scoped(to: "services.admin") +let customerConfig = config.scoped(to: "services.customer") + +// Using the admin API configuration +let adminBaseURL = adminConfig.string( +forKey: "baseURL", +default: "https://admin-api.example.com" +) +let adminTimeout = adminConfig.double( +forKey: "timeout", +default: 60.0 +) + +// Using the customer API configuration +let customerBaseURL = customerConfig.string( +forKey: "baseURL", +default: "https://customer-api.example.com" +) +let customerTimeout = customerConfig.double( +forKey: "timeout", +default: 30.0 +) + +This can be configured via environment variables as follows: + +# Admin API configuration +export SERVICES_ADMIN_BASE_URL="https://admin.internal-api.example.com" +export SERVICES_ADMIN_TIMEOUT=120.0 +export SERVICES_ADMIN_DEBUG_LOGGING=true + +# Customer API configuration +export SERVICES_CUSTOMER_BASE_URL="https://api.example.com" +export SERVICES_CUSTOMER_MAX_CONCURRENT_CONNECTIONS=20 +export SERVICES_CUSTOMER_TIMEOUT=15.0 + +For details about the key conversion logic, check out `EnvironmentVariablesProvider`. + +For more configuration guidance, see Adopting best practices. To understand different access patterns and reader methods, refer to Choosing the access pattern and Choosing reader methods. For handling secrets securely, check out Handling secrets correctly. + +## See Also + +### Essentials + +Configuring libraries + +Provide a consistent and flexible way to configure your library. + +Example use cases + +Review common use cases with ready-to-copy code samples. + +Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +- Configuring applications +- Overview +- Setting up a configuration hierarchy +- Configure your application +- Using scoped configuration +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/systempackage + +- Configuration +- SystemPackage + +Extended Module + +# SystemPackage + +## Topics + +### Extended Structures + +`extension FilePath` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence + +- Configuration +- ConfigUpdatesAsyncSequence + +Structure + +# ConfigUpdatesAsyncSequence + +A concrete async sequence for delivering updated configuration values. + +AsyncSequences.swift + +## Topics + +### Creating an asynchronous update sequence + +Creates a new concrete async sequence wrapping the provided existential sequence. + +## Relationships + +### Conforms To + +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` +- `_Concurrency.AsyncSequence` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +- ConfigUpdatesAsyncSequence +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/expressiblebyconfigstring + +- Configuration +- ExpressibleByConfigString + +Protocol + +# ExpressibleByConfigString + +A protocol for types that can be initialized from configuration string values. + +protocol ExpressibleByConfigString : CustomStringConvertible + +ExpressibleByConfigString.swift + +## Mentioned in + +Choosing reader methods + +## Overview + +Conform your custom types to this protocol to enable automatic conversion when using the `as:` parameter with configuration reader methods such as `string(forKey:as:isSecret:fileID:line:)`. + +## Custom types + +For other custom types, conform to the protocol `ExpressibleByConfigString` by providing a failable initializer and the `description` property: + +struct DatabaseURL: ExpressibleByConfigString { +let url: URL + +init?(configString: String) { +guard let url = URL(string: configString) else { return nil } +self.url = url +} + +var description: String { url.absoluteString } +} + +// Now you can use it with automatic conversion +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let dbUrl = config.string(forKey: "database.url", as: DatabaseURL.self) + +## Built-in conformances + +The following Foundation types already conform to `ExpressibleByConfigString`: + +- `SystemPackage.FilePath` \- Converts from file paths. + +- `Foundation.URL` \- Converts from URL strings. + +- `Foundation.UUID` \- Converts from UUID strings. + +- `Foundation.Date` \- Converts from ISO8601 date strings. + +## Topics + +### Required methods + +`init?(configString: String)` + +Creates an instance from a configuration string value. + +**Required** + +## Relationships + +### Inherits From + +- `Swift.CustomStringConvertible` + +### Conforming Types + +- `Date` +- `FilePath` +- `URL` +- `UUID` + +## See Also + +### Value conversion + +`protocol ConfigBytesFromStringDecoder` + +A protocol for decoding string configuration values into byte arrays. + +`struct ConfigBytesFromBase64StringDecoder` + +A decoder that converts base64-encoded strings into byte arrays. + +`struct ConfigBytesFromHexStringDecoder` + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +- ExpressibleByConfigString +- Mentioned in +- Overview +- Custom types +- Built-in conformances +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/using-in-memory-providers + +- Configuration +- Using in-memory providers + +Article + +# Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +## Overview + +Swift Configuration provides two in-memory providers, which are directly instantiated with the desired keys and values, rather than being parsed from another representation. These providers are particularly useful for testing, providing fallback values, and bridging with other configuration systems. + +- `InMemoryProvider` is an immutable value type, and can be useful for defining overrides and fallbacks in a provider hierarchy. + +- `MutableInMemoryProvider` is a mutable reference type, allowing you to update values and get any watchers notified automatically. It can be used to bridge from other stateful, callback-based configuration sources. + +### InMemoryProvider + +The `InMemoryProvider` is ideal for static configuration values that don’t change during application runtime. + +#### Basic usage + +Create an `InMemoryProvider` with a dictionary of configuration values: + +let provider = InMemoryProvider(values: [\ +"database.host": "localhost",\ +"database.port": 5432,\ +"api.timeout": 30.0,\ +"debug.enabled": true\ +]) + +let config = ConfigReader(provider: provider) +let host = config.string(forKey: "database.host") // "localhost" +let port = config.int(forKey: "database.port") // 5432 + +#### Using with hierarchical keys + +You can use `AbsoluteConfigKey` for more complex key structures: + +let provider = InMemoryProvider(values: [\ +AbsoluteConfigKey(["http", "client", "timeout"]): 30.0,\ +AbsoluteConfigKey(["http", "server", "port"]): 8080,\ +AbsoluteConfigKey(["logging", "level"]): "info"\ +]) + +#### Configuration context + +The in-memory provider performs exact matching of config keys, including the context. This allows you to provide different values for the same key path based on contextual information. + +The following example shows using two keys with the same key path, but different context, and giving them two different values: + +let provider = InMemoryProvider( +values: [\ +AbsoluteConfigKey(\ +["http", "client", "timeout"],\ +context: ["upstream": "example1.org"]\ +): 15.0,\ +AbsoluteConfigKey(\ +["http", "client", "timeout"],\ +context: ["upstream": "example2.org"]\ +): 30.0,\ +] +) + +With a provider configured this way, a config reader will return the following results: + +let config = ConfigReader(provider: provider) +config.double(forKey: "http.client.timeout") // nil +config.double( +forKey: ConfigKey( +"http.client.timeout", +context: ["upstream": "example1.org"] +) +) // 15.0 +config.double( +forKey: ConfigKey( +"http.client.timeout", +context: ["upstream": "example2.org"] +) +) // 30.0 + +### MutableInMemoryProvider + +The `MutableInMemoryProvider` allows you to modify configuration values at runtime and notify watchers of changes. + +#### Basic usage + +let provider = MutableInMemoryProvider() +provider.setValue("localhost", forKey: "database.host") +provider.setValue(5432, forKey: "database.port") + +let config = ConfigReader(provider: provider) +let host = config.string(forKey: "database.host") // "localhost" + +#### Updating values + +You can update values after creation, and any watchers will be notified: + +// Initial setup +provider.setValue("debug", forKey: "logging.level") + +// Later in your application, watchers are notified +provider.setValue("info", forKey: "logging.level") + +#### Watching for changes + +Use the provider’s async sequence to watch for configuration changes: + +let config = ConfigReader(provider: provider) +try await config.watchString( +forKey: "logging.level", +as: Logger.Level.self, +default: .debug +) { updates in +for try await level in updates { +print("Logging level changed to: \(level)") +} +} + +#### Testing + +In-memory providers are excellent for unit testing: + +func testDatabaseConnection() { +let testProvider = InMemoryProvider(values: [\ +"database.host": "test-db.example.com",\ +"database.port": 5433,\ +"database.name": "test_db"\ +]) + +let config = ConfigReader(provider: testProvider) +let connection = DatabaseConnection(config: config) +// Test your database connection logic +} + +#### Fallback values + +Use `InMemoryProvider` as a fallback in a provider hierarchy: + +let fallbackProvider = InMemoryProvider(values: [\ +"api.timeout": 30.0,\ +"retry.maxAttempts": 3,\ +"cache.enabled": true\ +]) + +let config = ConfigReader(providers: [\ +EnvironmentVariablesProvider(),\ +fallbackProvider\ +// Used when environment variables are not set\ +]) + +#### Bridging other systems + +Use `MutableInMemoryProvider` to bridge configuration from other systems: + +class ConfigurationBridge { +private let provider = MutableInMemoryProvider() + +func updateFromExternalSystem(_ values: [String: ConfigValue]) { +for (key, value) in values { +provider.setValue(value, forKey: key) +} +} +} + +For comparison with reloading providers, see Using reloading providers. To understand different access patterns and when to use each provider type, check out Choosing the access pattern. For more configuration guidance, refer to Adopting best practices. + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- Using in-memory providers +- Overview +- InMemoryProvider +- MutableInMemoryProvider +- Common Use Cases +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/snapshot() + +#app-main) + +- Configuration +- ConfigReader +- snapshot() + +Instance Method + +# snapshot() + +Returns a snapshot of the current configuration state. + +ConfigSnapshotReader.swift + +## Return Value + +The snapshot. + +## Discussion + +The snapshot reader provides read-only access to the configuration’s state at the time the method was called. + +let snapshot = config.snapshot() +// Use snapshot to read config values +let cert = snapshot.string(forKey: "cert") +let privateKey = snapshot.string(forKey: "privateKey") +// Ensures that both values are coming from the same underlying snapshot and that a provider +// didn't change its internal state between the two `string(...)` calls. +let identity = MyIdentity(cert: cert, privateKey: privateKey) + +## See Also + +### Reading from a snapshot + +Watches the configuration for changes. + +- snapshot() +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/best-practices + +- Configuration +- Adopting best practices + +Article + +# Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +## Overview + +When designing configuration for Swift libraries and applications, follow these patterns to create consistent, maintainable code that integrates well with the Swift ecosystem. + +### Document configuration keys + +Include thorough documentation about what configuration keys your library reads. For each key, document: + +- The key name and its hierarchical structure. + +- The expected data type. + +- Whether the key is required or optional. + +- Default values when applicable. + +- Valid value ranges or constraints. + +- Usage examples. + +public struct HTTPClientConfiguration { +/// ... +/// +/// ## Configuration keys: +/// - `timeout` (double, optional, default: 30.0): Request timeout in seconds. +/// - `maxRetries` (int, optional, default: 3, range: 0-10): Maximum retry attempts. +/// - `baseURL` (string, required): Base URL for requests. +/// - `apiKey` (string, required, secret): API authentication key. +/// +/// ... +public init(config: ConfigReader) { +// Implementation... +} +} + +### Use sensible defaults + +Provide reasonable default values to make your library work without extensive configuration. + +// Good: Provides sensible defaults +let timeout = config.double(forKey: "http.timeout", default: 30.0) +let maxConnections = config.int(forKey: "http.maxConnections", default: 10) + +// Avoid: Requiring configuration for common scenarios +let timeout = try config.requiredDouble(forKey: "http.timeout") // Forces users to configure + +### Use scoped configuration + +Organize your configuration keys logically using namespaces to keep related keys together. + +// Good: +let httpConfig = config.scoped(to: "http") +let timeout = httpConfig.double(forKey: "timeout", default: 30.0) +let retries = httpConfig.int(forKey: "retries", default: 3) + +// Better (in libraries): Offer a convenience method that reads your library's configuration. +// Tip: Read the configuration values from the provided reader directly, do not scope it +// to a "myLibrary" namespace. Instead, let the caller of MyLibraryConfiguration.init(config:) +// perform any scoping for your library's configuration. +public struct MyLibraryConfiguration { +public init(config: ConfigReader) { +self.timeout = config.double(forKey: "timeout", default: 30.0) +self.retries = config.int(forKey: "retries", default: 3) +} +} + +// Called from an app - the caller is responsible for adding a namespace and naming it, if desired. +let libraryConfig = MyLibraryConfiguration(config: config.scoped(to: "myLib")) + +### Mark secrets appropriately + +Mark sensitive configuration values like API keys, passwords, or tokens as secrets using the `isSecret: true` parameter. This tells access reporters to redact those values in logs. + +// Mark sensitive values as secrets +let apiKey = try config.requiredString(forKey: "api.key", isSecret: true) +let password = config.string(forKey: "database.password", default: nil, isSecret: true) + +// Regular values don't need the isSecret parameter +let timeout = config.double(forKey: "api.timeout", default: 30.0) + +Some providers also support the `SecretsSpecifier`, allowing you to mark which values are secret during application bootstrapping. + +For comprehensive guidance on handling secrets securely, see Handling secrets correctly. + +### Prefer optional over required + +Only mark configuration as required if your library absolutely cannot function without it. For most cases, provide sensible defaults and make configuration optional. + +// Good: Optional with sensible defaults +let timeout = config.double(forKey: "timeout", default: 30.0) +let debug = config.bool(forKey: "debug", default: false) + +// Use required only when absolutely necessary +let apiEndpoint = try config.requiredString(forKey: "api.endpoint") + +For more details, check out Choosing reader methods. + +### Validate configuration values + +Validate configuration values and throw meaningful errors for invalid input to catch configuration issues early. + +public init(config: ConfigReader) throws { +let timeout = config.double(forKey: "timeout", default: 30.0) + +throw MyConfigurationError.invalidTimeout("Timeout must be positive, got: \(timeout)") +} + +let maxRetries = config.int(forKey: "maxRetries", default: 3) + +throw MyConfigurationError.invalidRetryCount("Max retries must be 0-10, got: \(maxRetries)") +} + +self.timeout = timeout +self.maxRetries = maxRetries +} + +#### When to use reloading providers + +Use reloading providers when you need configuration changes to take effect without restarting your application: + +- Long-running services that can’t be restarted frequently. + +- Development environments where you iterate on configuration. + +- Applications that receive configuration updates through file deployments. + +Check out Using reloading providers to learn more. + +#### When to use static providers + +Use static providers when configuration doesn’t change during runtime: + +- Containerized applications with immutable configuration. + +- Applications where configuration is set once at startup. + +For help choosing between different access patterns and reader method variants, see Choosing the access pattern and Choosing reader methods. For troubleshooting configuration issues, refer to Troubleshooting and access reporting. + +## See Also + +### Essentials + +Configuring applications + +Provide flexible and consistent configuration for your application. + +Configuring libraries + +Provide a consistent and flexible way to configure your library. + +Example use cases + +Review common use cases with ready-to-copy code samples. + +- Adopting best practices +- Overview +- Document configuration keys +- Use sensible defaults +- Use scoped configuration +- Mark secrets appropriately +- Prefer optional over required +- Validate configuration values +- Choosing provider types +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier + +- Configuration +- SecretsSpecifier + +Enumeration + +# SecretsSpecifier + +A specification for identifying which configuration values contain sensitive information. + +SecretsSpecifier.swift + +## Mentioned in + +Adopting best practices + +Handling secrets correctly + +## Overview + +Configuration providers use secrets specifiers to determine which values should be marked as sensitive and protected from accidental disclosure in logs, debug output, or access reports. Secret values are handled specially by `AccessReporter` instances and other components that process configuration data. + +## Usage patterns + +### Mark all values as secret + +Use this for providers that exclusively handle sensitive data: + +let provider = InMemoryProvider( +values: ["api.key": "secret123", "db.password": "pass456"], +secretsSpecifier: .all +) + +### Mark specific keys as secret + +Use this when you know which specific keys contain sensitive information: + +let provider = EnvironmentVariablesProvider( +secretsSpecifier: .specific( +["API_KEY", "DATABASE_PASSWORD", "JWT_SECRET"] +) +) + +### Dynamic secret detection + +Use this for complex logic that determines secrecy based on key patterns or values: + +filePath: "/etc/config.json", +secretsSpecifier: .dynamic { key, value in +// Mark keys containing "password", +// "secret", or "token" as secret +key.lowercased().contains("password") || +key.lowercased().contains("secret") || +key.lowercased().contains("token") +} +) + +### No secret values + +Use this for providers that handle only non-sensitive configuration: + +let provider = InMemoryProvider( +values: ["app.name": "MyApp", "log.level": "info"], +secretsSpecifier: .none +) + +## Topics + +### Types of specifiers + +`case all` + +The library treats all configuration values as secrets. + +The library treats the specified keys as secrets. + +The library determines the secret status dynamically by evaluating each key-value pair. + +`case none` + +The library treats no configuration values as secrets. + +### Inspecting a secrets specifier + +Determines whether a configuration value should be treated as secret. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- SecretsSpecifier +- Mentioned in +- Overview +- Usage patterns +- Mark all values as secret +- Mark specific keys as secret +- Dynamic secret detection +- No secret values +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider + +- Configuration +- DirectoryFilesProvider + +Structure + +# DirectoryFilesProvider + +A configuration provider that reads values from individual files in a directory. + +struct DirectoryFilesProvider + +DirectoryFilesProvider.swift + +## Mentioned in + +Example use cases + +Handling secrets correctly + +Troubleshooting and access reporting + +## Overview + +This provider reads configuration values from a directory where each file represents a single configuration key-value pair. The file name becomes the configuration key, and the file contents become the value. This approach is commonly used by secret management systems that mount secrets as individual files. + +## Key mapping + +Configuration keys are transformed into file names using these rules: + +- Components are joined with dashes. + +- Non-alphanumeric characters (except dashes) are replaced with underscores. + +For example: + +## Value handling + +The provider reads file contents as UTF-8 strings and converts them to the requested type. For binary data (bytes type), it reads raw file contents directly without string conversion. Leading and trailing whitespace is always trimmed from string values. + +## Supported data types + +The provider supports all standard configuration types: + +- Strings (UTF-8 text files) + +- Integers, doubles, and booleans (parsed from string contents) + +- Arrays (using configurable separator, comma by default) + +- Byte arrays (raw file contents) + +## Secret handling + +By default, all values are marked as secrets for security. This is appropriate since this provider is typically used for sensitive data mounted by secret management systems. + +## Usage + +### Reading from a secrets directory + +// Assuming /run/secrets contains files: +// - database-password (contains: "secretpass123") +// - max-connections (contains: "100") +// - enable-cache (contains: "true") + +let provider = try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) + +let config = ConfigReader(provider: provider) +let dbPassword = config.string(forKey: "database.password") // "secretpass123" +let maxConn = config.int(forKey: "max.connections", default: 50) // 100 +let cacheEnabled = config.bool(forKey: "enable.cache", default: false) // true + +### Reading binary data + +// For binary files like certificates or keys +let provider = try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) + +let config = ConfigReader(provider: provider) +let certData = try config.requiredBytes(forKey: "tls.cert") // Raw file bytes + +### Custom array handling + +// If files contain comma-separated lists +let provider = try await DirectoryFilesProvider( +directoryPath: "/etc/config" +) + +// File "allowed-hosts" contains: "host1.example.com,host2.example.com,host3.example.com" +let hosts = config.stringArray(forKey: "allowed.hosts", default: []) +// ["host1.example.com", "host2.example.com", "host3.example.com"] + +## Configuration context + +This provider ignores the context passed in `context`. All keys are resolved using only their component path. + +## Topics + +### Creating a directory files provider + +Creates a new provider that reads files from a directory. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- DirectoryFilesProvider +- Mentioned in +- Overview +- Key mapping +- Value handling +- Supported data types +- Secret handling +- Usage +- Reading from a secrets directory +- Reading binary data +- Custom array handling +- Configuration context +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey + +- Configuration +- AbsoluteConfigKey + +Structure + +# AbsoluteConfigKey + +A configuration key that represents an absolute path to a configuration value. + +struct AbsoluteConfigKey + +ConfigKey.swift + +## Mentioned in + +Using in-memory providers + +## Overview + +Absolute configuration keys are similar to relative keys but represent complete paths from the root of the configuration hierarchy. They are used internally by the configuration system after resolving any key prefixes or scoping. + +Like relative keys, absolute keys consist of hierarchical components and optional context information. + +## Topics + +### Creating an absolute configuration key + +`init(ConfigKey)` + +Creates a new absolute configuration key from a relative key. + +[`init([String], context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/init(_:context:)) + +Creates a new absolute configuration key. + +### Inspecting an absolute configuration key + +[`var components: [String]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/components) + +The hierarchical components that make up this absolute configuration key. + +[`var context: [String : ConfigContextValue]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/context) + +Additional context information for this configuration key. + +### Instance Methods + +Returns a new absolute configuration key by appending the given relative key. + +Returns a new absolute configuration key by prepending the given relative key. + +## Relationships + +### Conforms To + +- `Swift.Comparable` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` +- `Swift.Equatable` +- `Swift.ExpressibleByArrayLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Configuration keys + +`struct ConfigKey` + +A configuration key representing a relative path to a configuration value. + +`enum ConfigContextValue` + +A value that can be stored in a configuration context. + +- AbsoluteConfigKey +- Mentioned in +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromhexstringdecoder + +- Configuration +- ConfigBytesFromHexStringDecoder + +Structure + +# ConfigBytesFromHexStringDecoder + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +struct ConfigBytesFromHexStringDecoder + +ConfigBytesFromStringDecoder.swift + +## Overview + +This decoder interprets string configuration values as hexadecimal-encoded data and converts them to their binary representation. It expects strings to contain only valid hexadecimal characters (0-9, A-F, a-f). + +## Hexadecimal format + +The decoder expects strings with an even number of characters, where each pair of characters represents one byte. For example, “48656C6C6F” represents the bytes for “Hello”. + +## Topics + +### Creating bytes from a hex string decoder + +`init()` + +Creates a new hexadecimal decoder. + +## Relationships + +### Conforms To + +- `ConfigBytesFromStringDecoder` +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Value conversion + +`protocol ExpressibleByConfigString` + +A protocol for types that can be initialized from configuration string values. + +`protocol ConfigBytesFromStringDecoder` + +A protocol for decoding string configuration values into byte arrays. + +`struct ConfigBytesFromBase64StringDecoder` + +A decoder that converts base64-encoded strings into byte arrays. + +- ConfigBytesFromHexStringDecoder +- Overview +- Hexadecimal format +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent + +- Configuration +- ConfigContent + +Enumeration + +# ConfigContent + +The raw content of a configuration value. + +@frozen +enum ConfigContent + +ConfigProvider.swift + +## Topics + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +## Relationships + +### Conforms To + +- `Swift.Copyable` +- `Swift.Equatable` +- `Swift.ExpressibleByBooleanLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByFloatLiteral` +- `Swift.ExpressibleByIntegerLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- ConfigContent +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue + +- Configuration +- ConfigValue + +Structure + +# ConfigValue + +A configuration value that wraps content with metadata. + +struct ConfigValue + +ConfigProvider.swift + +## Mentioned in + +Handling secrets correctly + +## Overview + +Configuration values pair raw content with a flag indicating whether the value contains sensitive information. Secret values are protected from accidental disclosure in logs and debug output: + +let apiKey = ConfigValue(.string("sk-abc123"), isSecret: true) + +## Topics + +### Creating a config value + +`init(ConfigContent, isSecret: Bool)` + +Creates a new configuration value. + +### Inspecting a config value + +`var content: ConfigContent` + +The configuration content. + +`var isSecret: Bool` + +Whether this value contains sensitive information that should not be logged. + +## Relationships + +### Conforms To + +- `Swift.Copyable` +- `Swift.CustomStringConvertible` +- `Swift.Equatable` +- `Swift.ExpressibleByBooleanLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByFloatLiteral` +- `Swift.ExpressibleByIntegerLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- ConfigValue +- Mentioned in +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/foundation + +- Configuration +- Foundation + +Extended Module + +# Foundation + +## Topics + +### Extended Structures + +`extension Date` + +`extension URL` + +`extension UUID` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader + +- Configuration +- ConfigSnapshotReader + +Structure + +# ConfigSnapshotReader + +A container type for reading config values from snapshots. + +struct ConfigSnapshotReader + +ConfigSnapshotReader.swift + +## Overview + +A config snapshot reader provides read-only access to config values stored in an underlying `ConfigSnapshot`. Unlike a config reader, which can access live, changing config values from providers, a snapshot reader works with a fixed, immutable snapshot of the configuration data. + +## Usage + +Get a snapshot reader from a config reader by using the `snapshot()` method. All values in the snapshot are guaranteed to be from the same point in time: + +// Get a snapshot from a ConfigReader +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let snapshot = config.snapshot() +// Use snapshot to read config values +let cert = snapshot.string(forKey: "cert") +let privateKey = snapshot.string(forKey: "privateKey") +// Ensures that both values are coming from the same +// underlying snapshot and that a provider didn't change +// its internal state between the two `string(...)` calls. +let identity = MyIdentity(cert: cert, privateKey: privateKey) + +Or you can watch for snapshot updates using the `watchSnapshot(fileID:line:updatesHandler:)` method: + +try await config.watchSnapshot { snapshots in +for await snapshot in snapshots { +// Process each new configuration snapshot +let cert = snapshot.string(forKey: "cert") +let privateKey = snapshot.string(forKey: "privateKey") +// Ensures that both values are coming from the same +// underlying snapshot and that a provider didn't change +// its internal state between the two `string(...)` calls. +let newCert = MyCert(cert: cert, privateKey: privateKey) +print("Certificate was updated: \(newCert.redactedDescription)") +} +} + +### Scoping + +Like `ConfigReader`, you can set a key prefix on the config snapshot reader, allowing all config lookups to prepend a prefix to the keys, which lets you pass a scoped snapshot reader to nested components. + +let httpConfig = snapshotReader.scoped(to: "http") +let timeout = httpConfig.int(forKey: "timeout") +// Reads from "http.timeout" in the snapshot + +### Config keys and context + +The library requests config values using a canonical “config key”, that represents a key path. You can provide additional context that was used by some providers when the snapshot was created. + +let httpTimeout = snapshotReader.int( +forKey: ConfigKey("http.timeout", context: ["upstream": "example.com"]), +default: 60 +) + +### Automatic type conversion + +String configuration values can be automatically converted to other types using the `as:` parameter. This works with: + +- Types that you explicitly conform to `ExpressibleByConfigString`. + +- Built-in types that already conform to `ExpressibleByConfigString`: + +- `SystemPackage.FilePath` \- Converts from file paths. + +- `Foundation.URL` \- Converts from URL strings. + +- `Foundation.UUID` \- Converts from UUID strings. + +- `Foundation.Date` \- Converts from ISO8601 date strings. + +// Built-in type conversion +let apiUrl = snapshot.string( +forKey: "api.url", +as: URL.self +) +let requestId = snapshot.string( +forKey: "request.id", +as: UUID.self +) + +enum LogLevel: String { +case debug, info, warning, error +} +let logLevel = snapshot.string( +forKey: "logging.level", +as: LogLevel.self, +default: .info +) + +// Custom type conversion (ExpressibleByConfigString) +struct DatabaseURL: ExpressibleByConfigString { +let url: URL + +init?(configString: String) { +guard let url = URL(string: configString) else { return nil } +self.url = url +} + +var description: String { url.absoluteString } +} +let dbUrl = snapshot.string( +forKey: "database.url", +as: DatabaseURL.self +) + +### Access reporting + +When reading from a snapshot, access events are reported to the access reporter from the original config reader. This helps debug which config values are accessed, even when reading from snapshots. + +## Topics + +### Creating a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +### Namespacing + +Returns a scoped snapshot reader by appending the provided key to the current key prefix. + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +### Synchronously reading lists of string values + +Synchronously gets an array of config values for the given config key, converting from strings. + +Synchronously gets an array of config values for the given config key with default fallback, converting from strings. + +### Synchronously reading required string values + +Synchronously gets a required config value for the given config key, throwing an error if it’s missing. + +Synchronously gets a required config value for the given config key, converting from string. + +### Synchronously reading required lists of string values + +Synchronously gets a required array of config values for the given config key, converting from strings. + +### Synchronously reading Boolean values + +### Synchronously reading required Boolean values + +### Synchronously reading lists of Boolean values + +### Synchronously reading required lists of Boolean values + +### Synchronously reading integer values + +### Synchronously reading required integer values + +### Synchronously reading lists of integer values + +### Synchronously reading required lists of integer values + +### Synchronously reading double values + +### Synchronously reading required double values + +### Synchronously reading lists of double values + +### Synchronously reading required lists of double values + +### Synchronously reading bytes + +### Synchronously reading required bytes + +### Synchronously reading collections of byte chunks + +### Synchronously reading required collections of byte chunks + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +- ConfigSnapshotReader +- Overview +- Usage +- Scoping +- Config keys and context +- Automatic type conversion +- Access reporting +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue + +- Configuration +- ConfigContextValue + +Enumeration + +# ConfigContextValue + +A value that can be stored in a configuration context. + +enum ConfigContextValue + +ConfigContext.swift + +## Overview + +Context values support common data types used for configuration metadata. + +## Topics + +### Configuration context values + +`case string(String)` + +A string value. + +`case bool(Bool)` + +A Boolean value. + +`case int(Int)` + +An integer value. + +`case double(Double)` + +A floating point value. + +## Relationships + +### Conforms To + +- `Swift.Copyable` +- `Swift.CustomStringConvertible` +- `Swift.Equatable` +- `Swift.ExpressibleByBooleanLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByFloatLiteral` +- `Swift.ExpressibleByIntegerLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Configuration keys + +`struct ConfigKey` + +A configuration key representing a relative path to a configuration value. + +`struct AbsoluteConfigKey` + +A configuration key that represents an absolute path to a configuration value. + +- ConfigContextValue +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader + +- Configuration +- ConfigReader + +Structure + +# ConfigReader + +A type that provides read-only access to configuration values from underlying providers. + +struct ConfigReader + +ConfigReader.swift + +## Mentioned in + +Configuring libraries + +Example use cases + +Using reloading providers + +## Overview + +Use `ConfigReader` to access configuration values from various sources like environment variables, JSON files, or in-memory stores. The reader supports provider hierarchies, key scoping, and access reporting for debugging configuration usage. + +## Usage + +To read configuration values, create a config reader with one or more providers: + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) + +### Using multiple providers + +Create a hierarchy of providers by passing an array to the initializer. The reader queries providers in order, using the first non-nil value it finds: + +do { +let config = ConfigReader(providers: [\ +// First, check environment variables\ +EnvironmentVariablesProvider(),\ +// Then, check a JSON config file\ + +// Finally, fall \ +]) + +// Uses the first provider that has a value for "http.timeout" +let timeout = config.int(forKey: "http.timeout", default: 15) +} catch { +print("Failed to create JSON provider: \(error)") +} + +The `get` and `fetch` methods query providers sequentially, while the `watch` method monitors all providers in parallel and returns the first non-nil value from the latest results. + +### Creating scoped readers + +Create a scoped reader to access nested configuration sections without repeating key prefixes. This is useful for passing configuration to specific components. + +Given this JSON configuration: + +{ +"http": { +"timeout": 60 +} +} + +Create a scoped reader for the HTTP section: + +let httpConfig = config.scoped(to: "http") +let timeout = httpConfig.int(forKey: "timeout") // Reads "http.timeout" + +### Understanding config keys + +The library accesses configuration values using config keys that represent a hierarchical path to the value. Internally, the library represents a key as a list of string components, such as `["http", "timeout"]`. + +### Using configuration context + +Provide additional context to help providers return more specific values. In the following example with a configuration that includes repeated configurations per “upstream”, the value returned is potentially constrained to the configuration with the matching context: + +let httpTimeout = config.int( +forKey: ConfigKey("http.timeout", context: ["upstream": "example.com"]), +default: 60 +) + +Providers can use this context to return specialized values or fall + +The library can automatically convert string configuration values to other types using the `as:` parameter. This works with: + +- Types that you explicitly conform to `ExpressibleByConfigString`. + +- Built-in types that already conform to `ExpressibleByConfigString`: + +- `SystemPackage.FilePath` \- Converts from file paths. + +- `Foundation.URL` \- Converts from URL strings. + +- `Foundation.UUID` \- Converts from UUID strings. + +- `Foundation.Date` \- Converts from ISO8601 date strings. + +// Built-in type conversion +let apiUrl = config.string(forKey: "api.url", as: URL.self) +let requestId = config.string( +forKey: "request.id", +as: UUID.self +) + +enum LogLevel: String { +case debug, info, warning, error +} +let logLevel = config.string( +forKey: "logging.level", +as: LogLevel.self, +default: .info +) + +// Custom type conversion (ExpressibleByConfigString) +struct DatabaseURL: ExpressibleByConfigString { +let url: URL + +init?(configString: String) { +guard let url = URL(string: configString) else { return nil } +self.url = url +} + +var description: String { url.absoluteString } +} +let dbUrl = config.string( +forKey: "database.url", +as: DatabaseURL.self +) + +### How providers encode keys + +Each `ConfigProvider` interprets config keys according to its data source format. For example, `EnvironmentVariablesProvider` converts `["http", "timeout"]` to the environment variable name `HTTP_TIMEOUT` by uppercasing components and joining with underscores. + +### Monitoring configuration access + +Use an access reporter to track which configuration values your application reads. The reporter receives `AccessEvent` instances containing the requested key, calling code location, returned value, and source provider. + +This helps debug configuration issues and to discover the config dependencies in your codebase. + +### Protecting sensitive values + +Mark sensitive configuration values as secrets to prevent logging by access loggers. Both config readers and providers can set the `isSecret` property. When either marks a value as sensitive, `AccessReporter` instances should not log the raw value. + +### Configuration context + +Configuration context supplements the configuration key components with extra metadata that providers can use to refine value lookups or return more specific results. Context is particularly useful for scenarios where the same configuration key might need different values based on runtime conditions. + +Create context using dictionary literal syntax with automatic type inference: + +let context: [String: ConfigContextValue] = [\ +"environment": "production",\ +"region": "us-west-2",\ +"timeout": 30,\ +"retryEnabled": true\ +] + +#### Provider behavior + +Not all providers use context information. Providers that support context can: + +- Return specialized values based on context keys. + +- Fall , +default: "localhost:5432" +) + +### Error handling behavior + +The config reader handles provider errors differently based on the method type: + +- **Get and watch methods**: Gracefully handle errors by returning `nil` or default values, except for “required” variants which rethrow errors. + +- **Fetch methods**: Always rethrow both provider and conversion errors. + +- **Required methods**: Rethrow all errors without fallback behavior. + +The library reports all provider errors to the access reporter through the `providerResults` array, even when handled gracefully. + +## Topics + +### Creating config readers + +`init(provider: some ConfigProvider, accessReporter: (any AccessReporter)?)` + +Creates a config reader with a single provider. + +[`init(providers: [any ConfigProvider], accessReporter: (any AccessReporter)?)`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/init(providers:accessreporter:)) + +Creates a config reader with multiple providers. + +### Retrieving a scoped config reader + +Returns a scoped config reader with the specified key appended to the current prefix. + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Readers and providers + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +- ConfigReader +- Mentioned in +- Overview +- Usage +- Using multiple providers +- Creating scoped readers +- Understanding config keys +- Using configuration context +- Automatic type conversion +- How providers encode keys +- Monitoring configuration access +- Protecting sensitive values +- Configuration context +- Error handling behavior +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromstringdecoder + +- Configuration +- ConfigBytesFromStringDecoder + +Protocol + +# ConfigBytesFromStringDecoder + +A protocol for decoding string configuration values into byte arrays. + +protocol ConfigBytesFromStringDecoder : Sendable + +ConfigBytesFromStringDecoder.swift + +## Overview + +This protocol defines the interface for converting string-based configuration values into binary data. Different implementations can support various encoding formats such as base64, hexadecimal, or other custom encodings. + +## Usage + +Implementations of this protocol are used by configuration providers that need to convert string values to binary data, such as cryptographic keys, certificates, or other binary configuration data. + +let decoder: ConfigBytesFromStringDecoder = .base64 +let bytes = decoder.decode("SGVsbG8gV29ybGQ=") // "Hello World" in base64 + +## Topics + +### Required methods + +Decodes a string value into an array of bytes. + +**Required** + +### Built-in decoders + +`static var base64: ConfigBytesFromBase64StringDecoder` + +A decoder that interprets string values as base64-encoded data. + +`static var hex: ConfigBytesFromHexStringDecoder` + +A decoder that interprets string values as hexadecimal-encoded data. + +## Relationships + +### Inherits From + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Conforming Types + +- `ConfigBytesFromBase64StringDecoder` +- `ConfigBytesFromHexStringDecoder` + +## See Also + +### Value conversion + +`protocol ExpressibleByConfigString` + +A protocol for types that can be initialized from configuration string values. + +`struct ConfigBytesFromBase64StringDecoder` + +A decoder that converts base64-encoded strings into byte arrays. + +`struct ConfigBytesFromHexStringDecoder` + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +- ConfigBytesFromStringDecoder +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfrombase64stringdecoder + +- Configuration +- ConfigBytesFromBase64StringDecoder + +Structure + +# ConfigBytesFromBase64StringDecoder + +A decoder that converts base64-encoded strings into byte arrays. + +struct ConfigBytesFromBase64StringDecoder + +ConfigBytesFromStringDecoder.swift + +## Overview + +This decoder interprets string configuration values as base64-encoded data and converts them to their binary representation. + +## Topics + +### Creating bytes from a base64 string + +`init()` + +Creates a new base64 decoder. + +## Relationships + +### Conforms To + +- `ConfigBytesFromStringDecoder` +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Value conversion + +`protocol ExpressibleByConfigString` + +A protocol for types that can be initialized from configuration string values. + +`protocol ConfigBytesFromStringDecoder` + +A protocol for decoding string configuration values into byte arrays. + +`struct ConfigBytesFromHexStringDecoder` + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +- ConfigBytesFromBase64StringDecoder +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype + +- Configuration +- ConfigType + +Enumeration + +# ConfigType + +The supported configuration value types. + +@frozen +enum ConfigType + +ConfigProvider.swift + +## Topics + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +### Initializers + +`init?(rawValue: String)` + +## Relationships + +### Conforms To + +- `Swift.BitwiseCopyable` +- `Swift.Equatable` +- `Swift.Hashable` +- `Swift.RawRepresentable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- ConfigType +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent + +- Configuration +- AccessEvent + +Structure + +# AccessEvent + +An event that captures information about accessing a configuration value. + +struct AccessEvent + +AccessReporter.swift + +## Overview + +Access events are generated whenever configuration values are accessed through `ConfigReader` and `ConfigSnapshotReader` methods. They contain metadata about the access, results from individual providers, and the final outcome of the operation. + +## Topics + +### Creating an access event + +Creates a configuration access event. + +`struct Metadata` + +Metadata describing the configuration access operation. + +`struct ProviderResult` + +The result of a configuration lookup from a specific provider. + +### Inspecting an access event + +The final outcome of the configuration access operation. + +`var conversionError: (any Error)?` + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +`var metadata: AccessEvent.Metadata` + +Metadata that describes the configuration access operation. + +[`var providerResults: [AccessEvent.ProviderResult]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresults) + +The results from each configuration provider that was queried. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Troubleshooting and access reporting + +Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +- AccessEvent +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/broadcastingaccessreporter + +- Configuration +- BroadcastingAccessReporter + +Structure + +# BroadcastingAccessReporter + +An access reporter that forwards events to multiple other reporters. + +struct BroadcastingAccessReporter + +AccessReporter.swift + +## Overview + +Use this reporter to send configuration access events to multiple destinations simultaneously. Each upstream reporter receives a copy of every event in the order they were provided during initialization. + +let fileLogger = try FileAccessLogger(filePath: "/tmp/config.log") +let accessLogger = AccessLogger(logger: logger) +let broadcaster = BroadcastingAccessReporter(upstreams: [fileLogger, accessLogger]) + +let config = ConfigReader( +provider: EnvironmentVariablesProvider(), +accessReporter: broadcaster +) + +## Topics + +### Creating a broadcasting access reporter + +[`init(upstreams: [any AccessReporter])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/broadcastingaccessreporter/init(upstreams:)) + +Creates a new broadcasting access reporter. + +## Relationships + +### Conforms To + +- `AccessReporter` +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Troubleshooting and access reporting + +Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +- BroadcastingAccessReporter +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/lookupresult + +- Configuration +- LookupResult + +Structure + +# LookupResult + +The result of looking up a configuration value in a provider. + +struct LookupResult + +ConfigProvider.swift + +## Overview + +Providers return this result from value lookup methods, containing both the encoded key used for the lookup and the value found: + +let result = try provider.value(forKey: key, type: .string) +if let value = result.value { +print("Found: \(value)") +} + +## Topics + +### Creating a lookup result + +`init(encodedKey: String, value: ConfigValue?)` + +Creates a lookup result. + +### Inspecting a lookup result + +`var encodedKey: String` + +The provider-specific encoding of the configuration key. + +`var value: ConfigValue?` + +The configuration value found for the key, or nil if not found. + +## Relationships + +### Conforms To + +- `Swift.Equatable` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- LookupResult +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/proposals + +- Configuration +- Proposals + +# Proposals + +Collaborate on API changes to Swift Configuration by writing a proposal. + +## Overview + +For non-trivial changes that affect the public API, the Swift Configuration project adopts a lightweight version of the Swift Evolution process. + +Writing a proposal first helps discuss multiple possible solutions early, apply useful feedback from other contributors, and avoid reimplementing the same feature multiple times. + +While it’s encouraged to get feedback by opening a pull request with a proposal early in the process, it’s also important to consider the complexity of the implementation when evaluating different solutions. For example, this might mean including a link to a branch containing a prototype implementation of the feature in the pull request description. + +### Steps + +1. Make sure there’s a GitHub issue for the feature or change you would like to propose. + +2. Duplicate the `SCO-NNNN.md` document and replace `NNNN` with the next available proposal number. + +3. Link the GitHub issue from your proposal, and fill in the proposal. + +4. Open a pull request with your proposal and solicit feedback from other contributors. + +5. Once a maintainer confirms that the proposal is ready for review, the state is updated accordingly. The review period is 7 days, and ends when one of the maintainers marks the proposal as Ready for Implementation, or Deferred. + +6. Before the pull request is merged, there should be an implementation ready, either in the same pull request, or a separate one, linked from the proposal. + +7. The proposal is considered Approved once the implementation, proposal PRs have been merged, and, if originally disabled by a feature flag, feature flag enabled unconditionally. + +If you have any questions, ask in an issue on GitHub. + +### Possible review states + +- Awaiting Review + +- In Review + +- Ready for Implementation + +- In Preview + +- Approved + +- Deferred + +## Topics + +SCO-NNNN: Feature name + +Feature abstract – a one sentence summary. + +SCO-0001: Generic file providers + +Introduce format-agnostic providers to simplify implementing additional file formats beyond JSON and YAML. + +SCO-0002: Remove custom key decoders + +Remove the custom key decoder feature to fix a flaw and simplify the project + +SCO-0003: Allow missing files in file providers + +Add an `allowMissing` parameter to file-based providers to handle missing configuration files gracefully. + +## See Also + +### Contributing + +Developing Swift Configuration + +Learn about tools and conventions used to develop the Swift Configuration package. + +- Proposals +- Overview +- Steps +- Possible review states +- Topics +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/inmemoryprovider + +- Configuration +- InMemoryProvider + +Structure + +# InMemoryProvider + +A configuration provider that stores values in memory. + +struct InMemoryProvider + +InMemoryProvider.swift + +## Mentioned in + +Using in-memory providers + +Configuring applications + +Example use cases + +## Overview + +This provider maintains a static dictionary of configuration values in memory, making it ideal for providing default values, overrides, or test configurations. Values are immutable once the provider is created and never change over time. + +## Use cases + +The in-memory provider is particularly useful for: + +- **Default configurations**: Providing fallback values when other providers don’t have a value + +- **Configuration overrides**: Taking precedence over other providers + +- **Testing**: Creating predictable configuration states for unit tests + +- **Static configurations**: Embedding compile-time configuration values + +## Value types + +The provider supports all standard configuration value types and automatically handles type validation. Values must match the requested type exactly - no automatic conversion is performed - for example, requesting a `String` value for a key that stores an `Int` value will throw an error. + +## Performance characteristics + +This provider offers O(1) lookup time and performs no I/O operations. All values are stored in memory. + +## Usage + +let provider = InMemoryProvider(values: [\ +"http.client.user-agent": "Config/1.0 (Test)",\ +"http.client.timeout": 15.0,\ +"http.secret": ConfigValue("s3cret", isSecret: true),\ +"http.version": 2,\ +"enabled": true\ +]) +// Prints all values, redacts "http.secret" automatically. +print(provider) +let config = ConfigReader(provider: provider) +let isEnabled = config.bool(forKey: "enabled", default: false) + +To learn more about the in-memory providers, check out Using in-memory providers. + +## Topics + +### Creating an in-memory provider + +[`init(name: String?, values: [AbsoluteConfigKey : ConfigValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/inmemoryprovider/init(name:values:)) + +Creates a new in-memory provider with the specified configuration values. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- InMemoryProvider +- Mentioned in +- Overview +- Use cases +- Value types +- Performance characteristics +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configuring-libraries + +- Configuration +- Configuring libraries + +Article + +# Configuring libraries + +Provide a consistent and flexible way to configure your library. + +## Overview + +Swift Configuration provides a pattern for configuring libraries that works across various configuration sources: environment variables, JSON files, and remote configuration services. + +This guide shows how to adopt this pattern in your library to make it easier to compose in larger applications. + +Adopt this pattern in three steps: + +1. Define your library’s configuration as a dedicated type (you might already have such a type in your library). + +2. Add a convenience method that accepts a `ConfigReader` \- can be an initializer, or a method that updates your configuration. + +3. Extract the individual configuration values using the provided reader. + +This approach makes your library configurable regardless of the user’s chosen configuration source and composes well with other libraries. + +### Define your configuration type + +Start by defining a type that encapsulates all the configuration options for your library. + +/// Configuration options for a hypothetical HTTPClient. +public struct HTTPClientConfiguration { +/// The timeout for network requests in seconds. +public var timeout: Double + +/// The maximum number of concurrent connections. +public var maxConcurrentConnections: Int + +/// Base URL for API requests. +public var baseURL: String + +/// Whether to enable debug logging. +public var debugLogging: Bool + +/// Create a configuration with explicit values. +public init( +timeout: Double = 30.0, +maxConcurrentConnections: Int = 5, +baseURL: String = "https://api.example.com", +debugLogging: Bool = false +) { +self.timeout = timeout +self.maxConcurrentConnections = maxConcurrentConnections +self.baseURL = baseURL +self.debugLogging = debugLogging +} +} + +### Add a convenience method + +Next, extend your configuration type to provide a method that accepts a `ConfigReader` as a parameter. In the example below, we use an initializer. + +extension HTTPClientConfiguration { +/// Creates a new HTTP client configuration using values from the provided reader. +/// +/// ## Configuration keys +/// - `timeout` (double, optional, default: `30.0`): The timeout for network requests in seconds. +/// - `maxConcurrentConnections` (int, optional, default: `5`): The maximum number of concurrent connections. +/// - `baseURL` (string, optional, default: `"https://api.example.com"`): Base URL for API requests. +/// - `debugLogging` (bool, optional, default: `false`): Whether to enable debug logging. +/// +/// - Parameter config: The config reader to read configuration values from. +public init(config: ConfigReader) { +self.timeout = config.double(forKey: "timeout", default: 30.0) +self.maxConcurrentConnections = config.int(forKey: "maxConcurrentConnections", default: 5) +self.baseURL = config.string(forKey: "baseURL", default: "https://api.example.com") +self.debugLogging = config.bool(forKey: "debugLogging", default: false) +} +} + +### Example: Adopting your library + +Once you’ve made your library configurable, users can easily configure it from various sources. Here’s how someone might configure your library using environment variables: + +import Configuration +import YourHTTPLibrary + +// Create a config reader from environment variables. +let config = ConfigReader(provider: EnvironmentVariablesProvider()) + +// Initialize your library's configuration from a config reader. +let httpConfig = HTTPClientConfiguration(config: config) + +// Create your library instance with the configuration. +let httpClient = HTTPClient(configuration: httpConfig) + +// Start using your library. +httpClient.get("/users") { response in +// Handle the response. +} + +With this approach, users can configure your library by setting environment variables that match your config keys: + +# Set configuration for your library through environment variables. +export TIMEOUT=60.0 +export MAX_CONCURRENT_CONNECTIONS=10 +export BASE_URL="https://api.production.com" +export DEBUG_LOGGING=true + +Your library now adapts to the user’s environment without any code changes. + +### Working with secrets + +Mark configuration values that contain sensitive information as secret to prevent them from being logged: + +extension HTTPClientConfiguration { +public init(config: ConfigReader) throws { +self.apiKey = try config.requiredString(forKey: "apiKey", isSecret: true) +// Other configuration... +} +} + +Built-in `AccessReporter` types such as `AccessLogger` and `FileAccessLogger` automatically redact secret values to avoid leaking sensitive information. + +For more guidance on secrets handling, see Handling secrets correctly. For more configuration guidance, check out Adopting best practices. To understand different access patterns and reader methods, refer to Choosing the access pattern and Choosing reader methods. + +## See Also + +### Essentials + +Configuring applications + +Provide flexible and consistent configuration for your application. + +Example use cases + +Review common use cases with ready-to-copy code samples. + +Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +- Configuring libraries +- Overview +- Define your configuration type +- Add a convenience method +- Example: Adopting your library +- Working with secrets +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/configsnapshot-implementations + +- Configuration +- YAMLSnapshot +- ConfigSnapshot Implementations + +API Collection + +# ConfigSnapshot Implementations + +## Topics + +### Instance Methods + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/init(data:providername:parsingoptions:) + +#app-main) + +- Configuration +- YAMLSnapshot +- init(data:providerName:parsingOptions:) + +Initializer + +# init(data:providerName:parsingOptions:) + +Inherited from `FileConfigSnapshot.init(data:providerName:parsingOptions:)`. + +convenience init( +data: RawSpan, +providerName: String, +parsingOptions: YAMLSnapshot.ParsingOptions +) throws + +YAMLSnapshot.swift + +## See Also + +### Creating a YAML snapshot + +`struct ParsingOptions` + +Custom input configuration for YAML snapshot creation. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/parsingoptions + +- Configuration +- YAMLSnapshot +- YAMLSnapshot.ParsingOptions + +Structure + +# YAMLSnapshot.ParsingOptions + +Custom input configuration for YAML snapshot creation. + +struct ParsingOptions + +YAMLSnapshot.swift + +## Overview + +This struct provides configuration options for parsing YAML data into configuration snapshots, including byte decoding and secrets specification. + +## Topics + +### Initializers + +Creates custom input configuration for YAML snapshots. + +### Instance Properties + +`var bytesDecoder: any ConfigBytesFromStringDecoder` + +A decoder of bytes from a string. + +A specifier for determining which configuration values should be treated as secrets. + +### Type Properties + +``static var `default`: YAMLSnapshot.ParsingOptions`` + +The default custom input configuration. + +## Relationships + +### Conforms To + +- `FileParsingOptions` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a YAML snapshot + +`convenience init(data: RawSpan, providerName: String, parsingOptions: YAMLSnapshot.ParsingOptions) throws` + +- YAMLSnapshot.ParsingOptions +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting/providercompattest + +- ConfigurationTesting +- ProviderCompatTest + +Structure + +# ProviderCompatTest + +A comprehensive test suite for validating `ConfigProvider` implementations. + +struct ProviderCompatTest + +ProviderCompatTest.swift + +## Overview + +This test suite verifies that configuration providers correctly implement all required functionality including synchronous and asynchronous value retrieval, snapshot operations, and value watching capabilities. + +## Usage + +Create a test instance with your provider and run the compatibility tests: + +let provider = MyCustomProvider() +let test = ProviderCompatTest(provider: provider) +try await test.runTest() + +## Required Test Data + +The provider under test must be populated with specific test values to ensure comprehensive validation. The required configuration data includes: + +\ +"string": String("Hello"),\ +"other.string": String("Other Hello"),\ +"int": Int(42),\ +"other.int": Int(24),\ +"double": Double(3.14),\ +"other.double": Double(2.72),\ +"bool": Bool(true),\ +"other.bool": Bool(false),\ +"bytes": [UInt8,\ +"other.bytes": UInt8,\ +"stringy.array": String,\ +"other.stringy.array": String,\ +"inty.array": Int,\ +"other.inty.array": Int,\ +"doubly.array": Double,\ +"other.doubly.array": Double,\ +"booly.array": Bool,\ +"other.booly.array": Bool,\ +"byteChunky.array": [[UInt8]]([.magic, .magic2]),\ +"other.byteChunky.array": [[UInt8]]([.magic, .magic2, .magic]),\ +] + +## Topics + +### Structures + +`struct TestConfiguration` + +Configuration options for customizing test behavior. + +### Initializers + +`init(provider: any ConfigProvider, configuration: ProviderCompatTest.TestConfiguration)` + +Creates a new compatibility test suite. + +### Instance Methods + +`func runTest() async throws` + +Executes the complete compatibility test suite. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +- ProviderCompatTest +- Overview +- Usage +- Required Test Data +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileconfigsnapshot + +- Configuration +- FileConfigSnapshot + +Protocol + +# FileConfigSnapshot + +A protocol for configuration snapshots created from file data. + +protocol FileConfigSnapshot : ConfigSnapshot, CustomDebugStringConvertible, CustomStringConvertible + +FileProviderSnapshot.swift + +## Overview + +This protocol extends `ConfigSnapshot` to provide file-specific functionality for creating configuration snapshots from raw file data. Types conforming to this protocol can parse various file formats (such as JSON and YAML) and convert them into configuration values. + +Commonly used with `FileProvider` and `ReloadingFileProvider`. + +## Implementation + +To create a custom file configuration snapshot: + +struct MyFormatSnapshot: FileConfigSnapshot { +typealias ParsingOptions = MyParsingOptions + +let values: [String: ConfigValue] +let providerName: String + +init(data: RawSpan, providerName: String, parsingOptions: MyParsingOptions) throws { +self.providerName = providerName +// Parse the data according to your format +self.values = try parseMyFormat(data, using: parsingOptions) +} +} + +The snapshot is responsible for parsing the file data and converting it into a representation of configuration values that can be queried by the configuration system. + +## Topics + +### Required methods + +`init(data: RawSpan, providerName: String, parsingOptions: Self.ParsingOptions) throws` + +Creates a new snapshot from file data. + +**Required** + +`associatedtype ParsingOptions : FileParsingOptions` + +The parsing options type used for parsing this snapshot. + +### Protocol requirements + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +## Relationships + +### Inherits From + +- `ConfigSnapshot` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Conforming Types + +- `JSONSnapshot` +- `YAMLSnapshot` + +- FileConfigSnapshot +- Overview +- Implementation +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/providername + +- Configuration +- YAMLSnapshot +- providerName + +Instance Property + +# providerName + +The name of the provider that created this snapshot. + +let providerName: String + +YAMLSnapshot.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/customdebugstringconvertible-implementations + +- Configuration +- YAMLSnapshot +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/customstringconvertible-implementations + +- Configuration +- YAMLSnapshot +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/fileconfigsnapshot-implementations + +- Configuration +- YAMLSnapshot +- FileConfigSnapshot Implementations + +API Collection + +# FileConfigSnapshot Implementations + +## Topics + +### Initializers + +`convenience init(data: RawSpan, providerName: String, parsingOptions: YAMLSnapshot.ParsingOptions) throws` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accesslogger/accessreporter-implementations + +- Configuration +- AccessLogger +- AccessReporter Implementations + +API Collection + +# AccessReporter Implementations + +## Topics + +### Instance Methods + +`func report(AccessEvent)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/init(data:providername:parsingoptions:) + +#app-main) + +- Configuration +- JSONSnapshot +- init(data:providerName:parsingOptions:) + +Initializer + +# init(data:providerName:parsingOptions:) + +Inherited from `FileConfigSnapshot.init(data:providerName:parsingOptions:)`. + +init( +data: RawSpan, +providerName: String, +parsingOptions: JSONSnapshot.ParsingOptions +) throws + +JSONSnapshot.swift + +## See Also + +### Creating a JSON snapshot + +`struct ParsingOptions` + +Parsing options for JSON snapshot creation. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/customstringconvertible-implementations + +- Configuration +- JSONSnapshot +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/parsingoptions + +- Configuration +- JSONSnapshot +- JSONSnapshot.ParsingOptions + +Structure + +# JSONSnapshot.ParsingOptions + +Parsing options for JSON snapshot creation. + +struct ParsingOptions + +JSONSnapshot.swift + +## Overview + +This struct provides configuration options for parsing JSON data into configuration snapshots, including byte decoding and secrets specification. + +## Topics + +### Initializers + +Creates parsing options for JSON snapshots. + +### Instance Properties + +`var bytesDecoder: any ConfigBytesFromStringDecoder` + +A decoder of bytes from a string. + +A specifier for determining which configuration values should be treated as secrets. + +### Type Properties + +``static var `default`: JSONSnapshot.ParsingOptions`` + +The default parsing options. + +## Relationships + +### Conforms To + +- `FileParsingOptions` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a JSON snapshot + +`init(data: RawSpan, providerName: String, parsingOptions: JSONSnapshot.ParsingOptions) throws` + +- JSONSnapshot.ParsingOptions +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/customdebugstringconvertible-implementations + +- Configuration +- JSONSnapshot +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/run() + +#app-main) + +- Configuration +- ReloadingFileProvider +- run() + +Instance Method + +# run() + +Inherited from `Service.run()`. + +func run() async throws + +ReloadingFileProvider.swift + +Available when `Snapshot` conforms to `FileConfigSnapshot`. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/providername + +- Configuration +- JSONSnapshot +- providerName + +Instance Property + +# providerName + +The name of the provider that created this snapshot. + +let providerName: String + +JSONSnapshot.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/fileconfigsnapshot-implementations + +- Configuration +- JSONSnapshot +- FileConfigSnapshot Implementations + +API Collection + +# FileConfigSnapshot Implementations + +## Topics + +### Initializers + +`init(data: RawSpan, providerName: String, parsingOptions: JSONSnapshot.ParsingOptions) throws` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accesslogger/init(logger:level:message:) + +#app-main) + +- Configuration +- AccessLogger +- init(logger:level:message:) + +Initializer + +# init(logger:level:message:) + +Creates a new access logger that reports configuration access events. + +init( +logger: Logger, +level: Logger.Level = .debug, +message: Logger.Message = "Config value accessed" +) + +AccessLogger.swift + +## Parameters + +`logger` + +The logger to emit access events to. + +`level` + +The log level for access events. Defaults to `.debug`. + +`message` + +The static message text for log entries. Defaults to “Config value accessed”. + +## Discussion + +let logger = Logger(label: "my.app.config") + +// Log at debug level by default +let accessLogger = AccessLogger(logger: logger) + +// Customize the log level +let accessLogger = AccessLogger(logger: logger, level: .info) + +- init(logger:level:message:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/providername + +- Configuration +- ReloadingFileProvider +- providerName + +Instance Property + +# providerName + +The human-readable name of the provider. + +let providerName: String + +ReloadingFileProvider.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/configsnapshot-implementations + +- Configuration +- JSONSnapshot +- ConfigSnapshot Implementations + +API Collection + +# ConfigSnapshot Implementations + +## Topics + +### Instance Methods + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/init(snapshottype:parsingoptions:filepath:allowmissing:pollinterval:logger:metrics:) + +#app-main) + +- Configuration +- ReloadingFileProvider +- init(snapshotType:parsingOptions:filePath:allowMissing:pollInterval:logger:metrics:) + +Initializer + +# init(snapshotType:parsingOptions:filePath:allowMissing:pollInterval:logger:metrics:) + +Creates a reloading file provider that monitors the specified file path. + +convenience init( +snapshotType: Snapshot.Type = Snapshot.self, +parsingOptions: Snapshot.ParsingOptions = .default, +filePath: FilePath, +allowMissing: Bool = false, +pollInterval: Duration = .seconds(15), +logger: Logger = Logger(label: "ReloadingFileProvider"), +metrics: any MetricsFactory = MetricsSystem.factory +) async throws + +ReloadingFileProvider.swift + +## Parameters + +`snapshotType` + +The type of snapshot to create from the file contents. + +`parsingOptions` + +Options used by the snapshot to parse the file data. + +`filePath` + +The path to the configuration file to monitor. + +`allowMissing` + +A flag controlling how the provider handles a missing file. + +- When `false` (the default), if the file is missing or malformed, throws an error. + +- When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +`pollInterval` + +How often to check for file changes. + +`logger` + +The logger instance to use for this provider. + +`metrics` + +The metrics factory to use for monitoring provider performance. + +## Discussion + +## See Also + +### Creating a reloading file provider + +`convenience init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, config: ConfigReader, logger: Logger, metrics: any MetricsFactory) async throws` + +Creates a reloading file provider using configuration from a reader. + +- init(snapshotType:parsingOptions:filePath:allowMissing:pollInterval:logger:metrics:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/customdebugstringconvertible-implementations + +- Configuration +- ReloadingFileProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/customstringconvertible-implementations + +- Configuration +- ReloadingFileProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/init(snapshottype:parsingoptions:config:) + +#app-main) + +- Configuration +- FileProvider +- init(snapshotType:parsingOptions:config:) + +Initializer + +# init(snapshotType:parsingOptions:config:) + +Creates a file provider using a file path from a configuration reader. + +init( +snapshotType: Snapshot.Type = Snapshot.self, +parsingOptions: Snapshot.ParsingOptions = .default, +config: ConfigReader +) async throws + +FileProvider.swift + +## Parameters + +`snapshotType` + +The type of snapshot to create from the file contents. + +`parsingOptions` + +Options used by the snapshot to parse the file data. + +`config` + +A configuration reader that contains the required configuration keys. + +## Discussion + +This initializer reads the file path from the provided configuration reader and creates a snapshot from that file. + +## Configuration keys + +- `filePath` (string, required): The path to the configuration file to read. + +- `allowMissing` (bool, optional, default: false): A flag controlling how the provider handles a missing file. When `false` (the default), if the file is missing or malformed, throws an error. When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +## See Also + +### Creating a file provider + +`init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, allowMissing: Bool) async throws` + +Creates a file provider that reads from the specified file path. + +- init(snapshotType:parsingOptions:config:) +- Parameters +- Discussion +- Configuration keys +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/customstringconvertible-implementations + +- Configuration +- FileProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/customdebugstringconvertible-implementations + +- Configuration +- FileProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/init(snapshottype:parsingoptions:filepath:allowmissing:) + +#app-main) + +- Configuration +- FileProvider +- init(snapshotType:parsingOptions:filePath:allowMissing:) + +Initializer + +# init(snapshotType:parsingOptions:filePath:allowMissing:) + +Creates a file provider that reads from the specified file path. + +init( +snapshotType: Snapshot.Type = Snapshot.self, +parsingOptions: Snapshot.ParsingOptions = .default, +filePath: FilePath, +allowMissing: Bool = false +) async throws + +FileProvider.swift + +## Parameters + +`snapshotType` + +The type of snapshot to create from the file contents. + +`parsingOptions` + +Options used by the snapshot to parse the file data. + +`filePath` + +The path to the configuration file to read. + +`allowMissing` + +A flag controlling how the provider handles a missing file. + +- When `false` (the default), if the file is missing or malformed, throws an error. + +- When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +## Discussion + +This initializer reads the file at the given path and creates a snapshot using the specified snapshot type. The file is read once during initialization. + +## See Also + +### Creating a file provider + +`init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, config: ConfigReader) async throws` + +Creates a file provider using a file path from a configuration reader. + +- init(snapshotType:parsingOptions:filePath:allowMissing:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/configprovider-implementations + +- Configuration +- ReloadingFileProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/init(environmentvariables:secretsspecifier:bytesdecoder:arrayseparator:) + +#app-main) + +- Configuration +- EnvironmentVariablesProvider +- init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:) + +Initializer + +# init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:) + +Creates a new provider from a custom dictionary of environment variables. + +init( +environmentVariables: [String : String], + +bytesDecoder: some ConfigBytesFromStringDecoder = .base64, +arraySeparator: Character = "," +) + +EnvironmentVariablesProvider.swift + +## Parameters + +`environmentVariables` + +A dictionary of environment variable names and values. + +`secretsSpecifier` + +Specifies which environment variables should be treated as secrets. + +`bytesDecoder` + +The decoder used for converting string values to byte arrays. + +`arraySeparator` + +The character used to separate elements in array values. + +## Discussion + +This initializer allows you to provide a custom set of environment variables, which is useful for testing or when you want to override specific values. + +let customEnvironment = [\ +"DATABASE_HOST": "localhost",\ +"DATABASE_PORT": "5432",\ +"API_KEY": "secret-key"\ +] +let provider = EnvironmentVariablesProvider( +environmentVariables: customEnvironment, +secretsSpecifier: .specific(["API_KEY"]) +) + +## See Also + +### Creating an environment variable provider + +Creates a new provider that reads from the current process environment. + +Creates a new provider that reads from an environment file. + +- init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/context + +- Configuration +- AbsoluteConfigKey +- context + +Instance Property + +# context + +Additional context information for this configuration key. + +var context: [String : ConfigContextValue] + +ConfigKey.swift + +## Discussion + +Context provides extra information that providers can use to refine lookups or return more specific values. Not all providers use context information. + +## See Also + +### Inspecting an absolute configuration key + +[`var components: [String]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/components) + +The hierarchical components that make up this absolute configuration key. + +- context +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider/customstringconvertible-implementations + +- Configuration +- CommandLineArgumentsProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/watchint(forkey:issecret:fileid:line:updateshandler:) + +#app-main) + +- Configuration +- ConfigReader +- watchInt(forKey:isSecret:fileID:line:updatesHandler:) + +Instance Method + +# watchInt(forKey:isSecret:fileID:line:updatesHandler:) + +Watches for updates to a config value for the given config key. + +forKey key: ConfigKey, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line, + +ConfigReader+methods.swift + +## Parameters + +`key` + +The config key to watch. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +`updatesHandler` + +A closure that handles an async sequence of updates to the value. The sequence produces `nil` if the value is missing or can’t be converted. + +## Return Value + +The result produced by the handler. + +## Mentioned in + +Example use cases + +## Discussion + +Use this method to observe changes to optional configuration values over time. The handler receives an async sequence that produces the current value whenever it changes, or `nil` if the value is missing or can’t be converted. + +try await config.watchInt(forKey: ["server", "port"]) { updates in +for await port in updates { +if let port = port { +print("Server port is: \(port)") +} else { +print("No server port configured") +} +} +} + +## See Also + +### Watching integer values + +Watches for updates to a config value for the given config key with default fallback. + +- watchInt(forKey:isSecret:fileID:line:updatesHandler:) +- Parameters +- Return Value +- Mentioned in +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/service-implementations + +- Configuration +- ReloadingFileProvider +- Service Implementations + +API Collection + +# Service Implementations + +## Topics + +### Instance Methods + +`func run() async throws` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/customstringconvertible-implementations + +- Configuration +- ConfigKey +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-6vten + +-6vten#app-main) + +- Configuration +- ConfigKey +- init(\_:context:) + +Initializer + +# init(\_:context:) + +Creates a new configuration key. + +init( +_ string: String, +context: [String : ConfigContextValue] = [:] +) + +ConfigKey.swift + +## Parameters + +`string` + +The string representation of the key path, for example `"http.timeout"`. + +`context` + +Additional context information for the key. + +## See Also + +### Creating a configuration key + +[`init([String], context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-9ifez) + +- init(\_:context:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/environmentvalue(forname:) + +#app-main) + +- Configuration +- EnvironmentVariablesProvider +- environmentValue(forName:) + +Instance Method + +# environmentValue(forName:) + +Returns the raw string value for a specific environment variable name. + +EnvironmentVariablesProvider.swift + +## Parameters + +`name` + +The exact name of the environment variable to retrieve. + +## Return Value + +The string value of the environment variable, or nil if not found. + +## Discussion + +This method provides direct access to environment variable values by name, without any key transformation or type conversion. It’s useful when you need to access environment variables that don’t follow the standard configuration key naming conventions. + +let provider = EnvironmentVariablesProvider() +let path = try provider.environmentValue(forName: "PATH") +let home = try provider.environmentValue(forName: "HOME") + +- environmentValue(forName:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/init(environmentfilepath:allowmissing:secretsspecifier:bytesdecoder:arrayseparator:) + +#app-main) + +- Configuration +- EnvironmentVariablesProvider +- init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:) + +Initializer + +# init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:) + +Creates a new provider that reads from an environment file. + +init( +environmentFilePath: FilePath, +allowMissing: Bool = false, + +bytesDecoder: some ConfigBytesFromStringDecoder = .base64, +arraySeparator: Character = "," +) async throws + +EnvironmentVariablesProvider.swift + +## Parameters + +`environmentFilePath` + +The file system path to the environment file to load. + +`allowMissing` + +A flag controlling how the provider handles a missing file. + +- When `false` (the default), if the file is missing or malformed, throws an error. + +- When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +`secretsSpecifier` + +Specifies which environment variables should be treated as secrets. + +`bytesDecoder` + +The decoder used for converting string values to byte arrays. + +`arraySeparator` + +The character used to separate elements in array values. + +## Discussion + +This initializer loads environment variables from an `.env` file at the specified path. The file should contain key-value pairs in the format `KEY=value`, one per line. Comments (lines starting with `#`) and empty lines are ignored. + +// Load from a .env file +let provider = try await EnvironmentVariablesProvider( +environmentFilePath: ".env", +allowMissing: true, +secretsSpecifier: .specific(["API_KEY"]) +) + +## See Also + +### Creating an environment variable provider + +Creates a new provider that reads from the current process environment. + +Creates a new provider from a custom dictionary of environment variables. + +- init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/customstringconvertible-implementations + +- Configuration +- EnvironmentVariablesProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider/init(upstream:keymapper:) + +#app-main) + +- Configuration +- KeyMappingProvider +- init(upstream:keyMapper:) + +Initializer + +# init(upstream:keyMapper:) + +Creates a new provider. + +init( +upstream: Upstream, + +) + +KeyMappingProvider.swift + +## Parameters + +`upstream` + +The upstream provider to delegate to after mapping. + +`mapKey` + +A closure to remap configuration keys. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configprovider/prefixkeys(with:) + +#app-main) + +- Configuration +- ConfigProvider +- prefixKeys(with:) + +Instance Method + +# prefixKeys(with:) + +Creates a new prefixed configuration provider. + +ConfigProvider+Operators.swift + +## Return Value + +A provider which prefixes keys with the given prefix. + +## Discussion + +- Parameter: prefix: The configuration key to prepend to all configuration keys. + +## See Also + +### Conveniences + +Implements `watchValue` by getting the current value and emitting it immediately. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Creates a new configuration provider where each key is rewritten by the given closure. + +- prefixKeys(with:) +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/customdebugstringconvertible-implementations + +- Configuration +- EnvironmentVariablesProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/configprovider-implementations + +- Configuration +- FileProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/init(snapshottype:parsingoptions:config:logger:metrics:) + +#app-main) + +- Configuration +- ReloadingFileProvider +- init(snapshotType:parsingOptions:config:logger:metrics:) + +Initializer + +# init(snapshotType:parsingOptions:config:logger:metrics:) + +Creates a reloading file provider using configuration from a reader. + +convenience init( +snapshotType: Snapshot.Type = Snapshot.self, +parsingOptions: Snapshot.ParsingOptions = .default, +config: ConfigReader, +logger: Logger = Logger(label: "ReloadingFileProvider"), +metrics: any MetricsFactory = MetricsSystem.factory +) async throws + +ReloadingFileProvider.swift + +## Parameters + +`snapshotType` + +The type of snapshot to create from the file contents. + +`parsingOptions` + +Options used by the snapshot to parse the file data. + +`config` + +A configuration reader that contains the required configuration keys. + +`logger` + +The logger instance to use for this provider. + +`metrics` + +The metrics factory to use for monitoring provider performance. + +## Configuration keys + +- `filePath` (string, required): The path to the configuration file to monitor. + +- `allowMissing` (bool, optional, default: false): A flag controlling how the provider handles a missing file. When `false` (the default), if the file is missing or malformed, throws an error. When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +- `pollIntervalSeconds` (int, optional, default: 15): How often to check for file changes in seconds. + +## See Also + +### Creating a reloading file provider + +`convenience init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, allowMissing: Bool, pollInterval: Duration, logger: Logger, metrics: any MetricsFactory) async throws` + +Creates a reloading file provider that monitors the specified file path. + +- init(snapshotType:parsingOptions:config:logger:metrics:) +- Parameters +- Configuration keys +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/equatable-implementations + +- Configuration +- ConfigKey +- Equatable Implementations + +API Collection + +# Equatable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/expressiblebyextendedgraphemeclusterliteral-implementations + +- Configuration +- ConfigKey +- ExpressibleByExtendedGraphemeClusterLiteral Implementations + +API Collection + +# ExpressibleByExtendedGraphemeClusterLiteral Implementations + +## Topics + +### Initializers + +`init(unicodeScalarLiteral: Self.ExtendedGraphemeClusterLiteralType)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/comparable-implementations + +- Configuration +- ConfigKey +- Comparable Implementations + +API Collection + +# Comparable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-9ifez + +-9ifez#app-main) + +- Configuration +- ConfigKey +- init(\_:context:) + +Initializer + +# init(\_:context:) + +Creates a new configuration key. + +init( +_ components: [String], +context: [String : ConfigContextValue] = [:] +) + +ConfigKey.swift + +## Parameters + +`components` + +The hierarchical components that make up the key path. + +`context` + +Additional context information for the key. + +## See Also + +### Creating a configuration key + +[`init(String, context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-6vten) + +- init(\_:context:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider/customdebugstringconvertible-implementations + +- Configuration +- KeyMappingProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider/init(arguments:secretsspecifier:bytesdecoder:) + +#app-main) + +- Configuration +- CommandLineArgumentsProvider +- init(arguments:secretsSpecifier:bytesDecoder:) + +Initializer + +# init(arguments:secretsSpecifier:bytesDecoder:) + +Creates a new CLI provider with the provided arguments. + +init( +arguments: [String] = CommandLine.arguments, + +bytesDecoder: some ConfigBytesFromStringDecoder = .base64 +) + +CommandLineArgumentsProvider.swift + +## Parameters + +`arguments` + +The command-line arguments to parse. + +`secretsSpecifier` + +Specifies which CLI arguments should be treated as secret. + +`bytesDecoder` + +The decoder used for converting string values into bytes. + +## Discussion + +// Uses the current process's arguments. +let provider = CommandLineArgumentsProvider() +// Uses custom arguments. +let provider = CommandLineArgumentsProvider(arguments: ["program", "--test", "--port", "8089"]) + +- init(arguments:secretsSpecifier:bytesDecoder:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromhexstringdecoder/configbytesfromstringdecoder-implementations + +- Configuration +- ConfigBytesFromHexStringDecoder +- ConfigBytesFromStringDecoder Implementations + +API Collection + +# ConfigBytesFromStringDecoder Implementations + +## Topics + +### Type Properties + +`static var hex: ConfigBytesFromHexStringDecoder` + +A decoder that interprets string values as hexadecimal-encoded data. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/init(name:initialvalues:) + +#app-main) + +- Configuration +- MutableInMemoryProvider +- init(name:initialValues:) + +Initializer + +# init(name:initialValues:) + +Creates a new mutable in-memory provider with the specified initial values. + +init( +name: String? = nil, +initialValues: [AbsoluteConfigKey : ConfigValue] +) + +MutableInMemoryProvider.swift + +## Parameters + +`name` + +An optional name for the provider, used in debugging and logging. + +`initialValues` + +A dictionary mapping absolute configuration keys to their initial values. + +## Discussion + +This initializer takes a dictionary of absolute configuration keys mapped to their initial values. The provider can be modified after creation using the `setValue(_:forKey:)` methods. + +let key1 = AbsoluteConfigKey(components: ["database", "host"], context: [:]) +let key2 = AbsoluteConfigKey(components: ["database", "port"], context: [:]) + +let provider = MutableInMemoryProvider( +name: "dynamic-config", +initialValues: [\ +key1: "localhost",\ +key2: 5432\ +] +) + +// Later, update values dynamically +provider.setValue("production-db", forKey: key1) + +- init(name:initialValues:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/expressiblebyarrayliteral-implementations + +- Configuration +- ConfigKey +- ExpressibleByArrayLiteral Implementations + +API Collection + +# ExpressibleByArrayLiteral Implementations + +## Topics + +### Initializers + +`init(arrayLiteral: String...)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/configprovider-implementations + +- Configuration +- EnvironmentVariablesProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/context + +- Configuration +- ConfigKey +- context + +Instance Property + +# context + +Additional context information for this configuration key. + +var context: [String : ConfigContextValue] + +ConfigKey.swift + +## Discussion + +Context provides extra information that providers can use to refine lookups or return more specific values. Not all providers use context information. + +## See Also + +### Inspecting a configuration key + +[`var components: [String]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/components) + +The hierarchical components that make up this configuration key. + +- context +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessreporter/report(_:) + +#app-main) + +- Configuration +- AccessReporter +- report(\_:) + +Instance Method + +# report(\_:) + +Processes a configuration access event. + +func report(_ event: AccessEvent) + +AccessReporter.swift + +**Required** + +## Parameters + +`event` + +The configuration access event to process. + +## Discussion + +This method is called whenever a configuration value is accessed through a `ConfigReader` or a `ConfigSnapshotReader`. Implementations should handle events efficiently as they may be called frequently. + +- report(\_:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider/customdebugstringconvertible-implementations + +- Configuration +- CommandLineArgumentsProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.doubleArray(\_:) + +Case + +# ConfigContent.doubleArray(\_:) + +An array of double values. + +case doubleArray([Double]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/expressiblebyextendedgraphemeclusterliteral-implementations + +- Configuration +- ConfigContextValue +- ExpressibleByExtendedGraphemeClusterLiteral Implementations + +API Collection + +# ExpressibleByExtendedGraphemeClusterLiteral Implementations + +## Topics + +### Initializers + +`init(unicodeScalarLiteral: Self.ExtendedGraphemeClusterLiteralType)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/specific(_:) + +#app-main) + +- Configuration +- SecretsSpecifier +- SecretsSpecifier.specific(\_:) + +Case + +# SecretsSpecifier.specific(\_:) + +The library treats the specified keys as secrets. + +SecretsSpecifier.swift + +## Parameters + +`keys` + +The set of keys that should be treated as secrets. + +## Discussion + +Use this case when you have a known set of keys that contain sensitive information. All other keys will be treated as non-secret. + +## See Also + +### Types of specifiers + +`case all` + +The library treats all configuration values as secrets. + +The library determines the secret status dynamically by evaluating each key-value pair. + +`case none` + +The library treats no configuration values as secrets. + +- SecretsSpecifier.specific(\_:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileconfigsnapshot/init(data:providername:parsingoptions:) + +# An unknown error occurred. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider/init(directorypath:allowmissing:secretsspecifier:arrayseparator:) + +#app-main) + +- Configuration +- DirectoryFilesProvider +- init(directoryPath:allowMissing:secretsSpecifier:arraySeparator:) + +Initializer + +# init(directoryPath:allowMissing:secretsSpecifier:arraySeparator:) + +Creates a new provider that reads files from a directory. + +init( +directoryPath: FilePath, +allowMissing: Bool = false, + +arraySeparator: Character = "," +) async throws + +DirectoryFilesProvider.swift + +## Parameters + +`directoryPath` + +The file system path to the directory containing configuration files. + +`allowMissing` + +A flag controlling how the provider handles a missing directory. + +- When `false`, if the directory is missing, throws an error. + +- When `true`, if the directory is missing, treats it as empty. + +`secretsSpecifier` + +Specifies which values should be treated as secrets. + +`arraySeparator` + +The character used to separate elements in array values. + +## Discussion + +This initializer scans the specified directory and loads all regular files as configuration values. Subdirectories are not traversed. Hidden files (starting with a dot) are skipped. + +// Load configuration from a directory +let provider = try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) + +- init(directoryPath:allowMissing:secretsSpecifier:arraySeparator:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/string + +- Configuration +- ConfigType +- ConfigType.string + +Case + +# ConfigType.string + +A string value. + +case string + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/string(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.string(\_:) + +Case + +# ConfigContent.string(\_:) + +A string value. + +case string(String) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider/configprovider-implementations + +- Configuration +- KeyMappingProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.boolArray(\_:) + +Case + +# ConfigContent.boolArray(\_:) + +An array of Boolean value. + +case boolArray([Bool]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/init(metadata:providerresults:conversionerror:result:) + +#app-main) + +- Configuration +- AccessEvent +- init(metadata:providerResults:conversionError:result:) + +Initializer + +# init(metadata:providerResults:conversionError:result:) + +Creates a configuration access event. + +init( +metadata: AccessEvent.Metadata, +providerResults: [AccessEvent.ProviderResult], +conversionError: (any Error)? = nil, + +AccessReporter.swift + +## Parameters + +`metadata` + +Metadata describing the access operation. + +`providerResults` + +The results from each provider queried. + +`conversionError` + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +`result` + +The final outcome of the access operation. + +## See Also + +### Creating an access event + +`struct Metadata` + +Metadata describing the configuration access operation. + +`struct ProviderResult` + +The result of a configuration lookup from a specific provider. + +- init(metadata:providerResults:conversionError:result:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/setvalue(_:forkey:) + +#app-main) + +- Configuration +- MutableInMemoryProvider +- setValue(\_:forKey:) + +Instance Method + +# setValue(\_:forKey:) + +Updates the stored value for the specified configuration key. + +func setValue( +_ value: ConfigValue?, +forKey key: AbsoluteConfigKey +) + +MutableInMemoryProvider.swift + +## Parameters + +`value` + +The new configuration value, or `nil` to remove the value entirely. + +`key` + +The absolute configuration key to update. + +## Discussion + +This method atomically updates the value and notifies all active watchers of the change. If the new value is the same as the existing value, no notification is sent. + +let provider = MutableInMemoryProvider(initialValues: [:]) +let key = AbsoluteConfigKey(components: ["api", "enabled"], context: [:]) + +// Set a new value +provider.setValue(true, forKey: key) + +// Remove a value +provider.setValue(nil, forKey: key) + +- setValue(\_:forKey:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileparsingoptions/default + +- Configuration +- FileParsingOptions +- default + +Type Property + +# default + +The default instance of this options type. + +static var `default`: Self { get } + +FileProviderSnapshot.swift + +**Required** + +## Discussion + +This property provides a default configuration that can be used when no parsing options are specified. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/init(secretsspecifier:bytesdecoder:arrayseparator:) + +#app-main) + +- Configuration +- EnvironmentVariablesProvider +- init(secretsSpecifier:bytesDecoder:arraySeparator:) + +Initializer + +# init(secretsSpecifier:bytesDecoder:arraySeparator:) + +Creates a new provider that reads from the current process environment. + +init( + +bytesDecoder: some ConfigBytesFromStringDecoder = .base64, +arraySeparator: Character = "," +) + +EnvironmentVariablesProvider.swift + +## Parameters + +`secretsSpecifier` + +Specifies which environment variables should be treated as secrets. + +`bytesDecoder` + +The decoder used for converting string values to byte arrays. + +`arraySeparator` + +The character used to separate elements in array values. + +## Discussion + +This initializer creates a provider that sources configuration values from the environment variables of the current process. + +// Basic usage +let provider = EnvironmentVariablesProvider() + +// With secret handling +let provider = EnvironmentVariablesProvider( +secretsSpecifier: .specific(["API_KEY", "DATABASE_PASSWORD"]) +) + +## See Also + +### Creating an environment variable provider + +Creates a new provider from a custom dictionary of environment variables. + +Creates a new provider that reads from an environment file. + +- init(secretsSpecifier:bytesDecoder:arraySeparator:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/expressiblebystringliteral-implementations + +- Configuration +- ConfigKey +- ExpressibleByStringLiteral Implementations + +API Collection + +# ExpressibleByStringLiteral Implementations + +## Topics + +### Initializers + +`init(extendedGraphemeClusterLiteral: Self.StringLiteralType)` + +`init(stringLiteral: String)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/components + +- Configuration +- ConfigKey +- components + +Instance Property + +# components + +The hierarchical components that make up this configuration key. + +var components: [String] + +ConfigKey.swift + +## Discussion + +Each component represents a level in the configuration hierarchy. For example, `["database", "connection", "timeout"]` represents a three-level nested key. + +## See Also + +### Inspecting a configuration key + +[`var context: [String : ConfigContextValue]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/context) + +Additional context information for this configuration key. + +- components +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/init(_:) + +#app-main) + +- Configuration +- ConfigUpdatesAsyncSequence +- init(\_:) + +Initializer + +# init(\_:) + +Creates a new concrete async sequence wrapping the provided existential sequence. + +AsyncSequences.swift + +## Parameters + +`upstream` + +The async sequence to wrap. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/foundation/uuid + +- Configuration +- Foundation +- UUID + +Extended Structure + +# UUID + +ConfigurationFoundation + +extension UUID + +## Topics + +## Relationships + +### Conforms To + +- `ExpressibleByConfigString` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` + +- UUID +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/customstringconvertible-implementations + +- Configuration +- MutableInMemoryProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfrombase64stringdecoder/init() + +#app-main) + +- Configuration +- ConfigBytesFromBase64StringDecoder +- init() + +Initializer + +# init() + +Creates a new base64 decoder. + +init() + +ConfigBytesFromStringDecoder.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider/configprovider-implementations + +- Configuration +- CommandLineArgumentsProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/customstringconvertible-implementations + +- Configuration +- ConfigValue +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/string(forkey:as:issecret:fileid:line:)-4oust + +-4oust#app-main) + +- Configuration +- ConfigReader +- string(forKey:as:isSecret:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve configuration values that can be converted from strings, such as custom types conforming to string conversion protocols. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let serverMode = config.string(forKey: ["server", "mode"], as: ServerMode.self) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:as:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.bytes(\_:) + +Case + +# ConfigContent.bytes(\_:) + +An array of bytes. + +case bytes([UInt8]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/content + +- Configuration +- ConfigValue +- content + +Instance Property + +# content + +The configuration content. + +var content: ConfigContent + +ConfigProvider.swift + +## See Also + +### Inspecting a config value + +`var isSecret: Bool` + +Whether this value contains sensitive information that should not be logged. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/watchsnapshot(fileid:line:updateshandler:) + +#app-main) + +- Configuration +- ConfigReader +- watchSnapshot(fileID:line:updatesHandler:) + +Instance Method + +# watchSnapshot(fileID:line:updatesHandler:) + +Watches the configuration for changes. + +fileID: String = #fileID, +line: UInt = #line, + +ConfigSnapshotReader.swift + +## Parameters + +`fileID` + +The file where this method is called from. + +`line` + +The line where this method is called from. + +`updatesHandler` + +A closure that receives an async sequence of `ConfigSnapshotReader` instances. + +## Return Value + +The value returned by the handler. + +## Discussion + +This method watches the configuration for changes and provides a stream of snapshots to the handler closure. Each snapshot represents the configuration state at a specific point in time. + +try await config.watchSnapshot { snapshots in +for await snapshot in snapshots { +// Process each new configuration snapshot +let cert = snapshot.string(forKey: "cert") +let privateKey = snapshot.string(forKey: "privateKey") +// Ensures that both values are coming from the same underlying snapshot and that a provider +// didn't change its internal state between the two `string(...)` calls. +let newCert = MyCert(cert: cert, privateKey: privateKey) +print("Certificate was updated: \(newCert.redactedDescription)") +} +} + +## See Also + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +- watchSnapshot(fileID:line:updatesHandler:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/configprovider-implementations + +- Configuration +- MutableInMemoryProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/expressiblebybooleanliteral-implementations + +- Configuration +- ConfigValue +- ExpressibleByBooleanLiteral Implementations + +API Collection + +# ExpressibleByBooleanLiteral Implementations + +## Topics + +### Initializers + +`init(booleanLiteral: Bool)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider/configprovider-implementations + +- Configuration +- DirectoryFilesProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/int(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.int(\_:) + +Case + +# ConfigContent.int(\_:) + +An integer value. + +case int(Int) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/none + +- Configuration +- SecretsSpecifier +- SecretsSpecifier.none + +Case + +# SecretsSpecifier.none + +The library treats no configuration values as secrets. + +case none + +SecretsSpecifier.swift + +## Discussion + +Use this case when the provider handles only non-sensitive configuration data that can be safely logged or displayed. + +## See Also + +### Types of specifiers + +`case all` + +The library treats all configuration values as secrets. + +The library treats the specified keys as secrets. + +The library determines the secret status dynamically by evaluating each key-value pair. + +- SecretsSpecifier.none +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.byteChunkArray(\_:) + +Case + +# ConfigContent.byteChunkArray(\_:) + +An array of byte arrays. + +case byteChunkArray([[UInt8]]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/issecret + +- Configuration +- ConfigValue +- isSecret + +Instance Property + +# isSecret + +Whether this value contains sensitive information that should not be logged. + +var isSecret: Bool + +ConfigProvider.swift + +## See Also + +### Inspecting a config value + +`var content: ConfigContent` + +The configuration content. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromhexstringdecoder/init() + +#app-main) + +- Configuration +- ConfigBytesFromHexStringDecoder +- init() + +Initializer + +# init() + +Creates a new hexadecimal decoder. + +init() + +ConfigBytesFromStringDecoder.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/expressiblebybooleanliteral-implementations + +- Configuration +- ConfigContextValue +- ExpressibleByBooleanLiteral Implementations + +API Collection + +# ExpressibleByBooleanLiteral Implementations + +## Topics + +### Initializers + +`init(booleanLiteral: Bool)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/result + +- Configuration +- AccessEvent +- result + +Instance Property + +# result + +The final outcome of the configuration access operation. + +AccessReporter.swift + +## Discussion + +## See Also + +### Inspecting an access event + +`var conversionError: (any Error)?` + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +`var metadata: AccessEvent.Metadata` + +Metadata that describes the configuration access operation. + +[`var providerResults: [AccessEvent.ProviderResult]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresults) + +The results from each configuration provider that was queried. + +- result +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/scoped(to:) + +#app-main) + +- Configuration +- ConfigSnapshotReader +- scoped(to:) + +Instance Method + +# scoped(to:) + +Returns a scoped snapshot reader by appending the provided key to the current key prefix. + +ConfigSnapshotReader.swift + +## Parameters + +`configKey` + +The key to append to the current key prefix. + +## Return Value + +A reader for accessing scoped values. + +## Discussion + +Use this method to create a reader that accesses a subset of the configuration. + +let httpConfig = snapshotReader.scoped(to: ["client", "http"]) +let timeout = httpConfig.int(forKey: "timeout") // Reads from "client.http.timeout" in the snapshot + +- scoped(to:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/broadcastingaccessreporter/accessreporter-implementations + +- Configuration +- BroadcastingAccessReporter +- AccessReporter Implementations + +API Collection + +# AccessReporter Implementations + +## Topics + +### Instance Methods + +`func report(AccessEvent)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:as:issecret:fileid:line:)-7bpif + +-7bpif#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:as:isSecret:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve configuration values that can be converted from strings, such as custom types conforming to string conversion protocols. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let serverMode = snapshot.string(forKey: ["server", "mode"], as: ServerMode.self) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:as:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/customstringconvertible-implementations + +- Configuration +- AbsoluteConfigKey +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider/customstringconvertible-implementations + +- Configuration +- KeyMappingProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/bool(_:) + +#app-main) + +- Configuration +- ConfigContextValue +- ConfigContextValue.bool(\_:) + +Case + +# ConfigContextValue.bool(\_:) + +A Boolean value. + +case bool(Bool) + +ConfigContext.swift + +## See Also + +### Configuration context values + +`case string(String)` + +A string value. + +`case int(Int)` + +An integer value. + +`case double(Double)` + +A floating point value. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader-fetch + +- Configuration +- ConfigReader +- Asynchronously fetching values + +API Collection + +# Asynchronously fetching values + +## Topics + +### Asynchronously fetching string values + +Asynchronously fetches a config value for the given config key. + +Asynchronously fetches a config value for the given config key, with a default fallback. + +Asynchronously fetches a config value for the given config key, converting from string. + +Asynchronously fetches a config value for the given config key with default fallback, converting from string. + +### Asynchronously fetching lists of string values + +Asynchronously fetches an array of config values for the given config key, converting from strings. + +Asynchronously fetches an array of config values for the given config key with default fallback, converting from strings. + +### Asynchronously fetching required string values + +Asynchronously fetches a required config value for the given config key, throwing an error if it’s missing. + +Asynchronously fetches a required config value for the given config key, converting from string. + +### Asynchronously fetching required lists of string values + +Asynchronously fetches a required array of config values for the given config key, converting from strings. + +### Asynchronously fetching Boolean values + +### Asynchronously fetching required Boolean values + +### Asynchronously fetching lists of Boolean values + +### Asynchronously fetching required lists of Boolean values + +### Asynchronously fetching integer values + +### Asynchronously fetching required integer values + +### Asynchronously fetching lists of integer values + +### Asynchronously fetching required lists of integer values + +### Asynchronously fetching double values + +### Asynchronously fetching required double values + +### Asynchronously fetching lists of double values + +### Asynchronously fetching required lists of double values + +### Asynchronously fetching bytes + +### Asynchronously fetching required bytes + +### Asynchronously fetching lists of byte chunks + +### Asynchronously fetching required lists of byte chunks + +## See Also + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +- Asynchronously fetching values +- Topics +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader-watch + +- Configuration +- ConfigReader +- Watching values + +API Collection + +# Watching values + +## Topics + +### Watching string values + +Watches for updates to a config value for the given config key. + +Watches for updates to a config value for the given config key, converting from string. + +Watches for updates to a config value for the given config key with default fallback. + +Watches for updates to a config value for the given config key with default fallback, converting from string. + +### Watching required string values + +Watches for updates to a required config value for the given config key. + +Watches for updates to a required config value for the given config key, converting from string. + +### Watching lists of string values + +Watches for updates to an array of config values for the given config key, converting from strings. + +Watches for updates to an array of config values for the given config key with default fallback, converting from strings. + +### Watching required lists of string values + +Watches for updates to a required array of config values for the given config key, converting from strings. + +### Watching Boolean values + +### Watching required Boolean values + +### Watching lists of Boolean values + +### Watching required lists of Boolean values + +### Watching integer values + +### Watching required integer values + +### Watching lists of integer values + +### Watching required lists of integer values + +### Watching double values + +### Watching required double values + +### Watching lists of double values + +### Watching required lists of double values + +### Watching bytes + +### Watching required bytes + +### Watching lists of byte chunks + +### Watching required lists of byte chunks + +## See Also + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +- Watching values +- Topics +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/parsingoptions/init(bytesdecoder:secretsspecifier:) + +#app-main) + +- Configuration +- YAMLSnapshot +- YAMLSnapshot.ParsingOptions +- init(bytesDecoder:secretsSpecifier:) + +Initializer + +# init(bytesDecoder:secretsSpecifier:) + +Creates custom input configuration for YAML snapshots. + +init( +bytesDecoder: some ConfigBytesFromStringDecoder = .base64, + +) + +YAMLSnapshot.swift + +## Parameters + +`bytesDecoder` + +The decoder to use for converting string values to byte arrays. + +`secretsSpecifier` + +The specifier for identifying secret values. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:as:issecret:default:fileid:line:)-2mphx + +-2mphx#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:as:isSecret:default:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:default:fileID:line:) + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +default defaultValue: Value, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`defaultValue` + +The fallback value returned when the config value is missing or invalid. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The config value if found and convertible, otherwise the default value. + +## Discussion + +Use this method when you need a guaranteed non-nil result for string-convertible types. If the configuration value is missing or can’t be converted to the expected type, the default value is returned instead. + +let serverMode = snapshot.string(forKey: ["server", "mode"], as: ServerMode.self, default: .production) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +- string(forKey:as:isSecret:default:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/conversionerror + +- Configuration +- AccessEvent +- conversionError + +Instance Property + +# conversionError + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +var conversionError: (any Error)? + +AccessReporter.swift + +## See Also + +### Inspecting an access event + +The final outcome of the configuration access operation. + +`var metadata: AccessEvent.Metadata` + +Metadata that describes the configuration access operation. + +[`var providerResults: [AccessEvent.ProviderResult]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresults) + +The results from each configuration provider that was queried. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromstringdecoder/decode(_:) + +#app-main) + +- Configuration +- ConfigBytesFromStringDecoder +- decode(\_:) + +Instance Method + +# decode(\_:) + +Decodes a string value into an array of bytes. + +ConfigBytesFromStringDecoder.swift + +**Required** + +## Parameters + +`value` + +The string representation to decode. + +## Return Value + +An array of bytes if decoding succeeds, or `nil` if it fails. + +## Discussion + +This method attempts to parse the provided string according to the decoder’s specific format and returns the corresponding byte array. If the string cannot be decoded (due to invalid format or encoding), the method returns `nil`. + +- decode(\_:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader-get + +- Configuration +- ConfigReader +- Synchronously reading values + +API Collection + +# Synchronously reading values + +## Topics + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +### Synchronously reading lists of string values + +Synchronously gets an array of config values for the given config key, converting from strings. + +Synchronously gets an array of config values for the given config key with default fallback, converting from strings. + +### Synchronously reading required string values + +Synchronously gets a required config value for the given config key, throwing an error if it’s missing. + +Synchronously gets a required config value for the given config key, converting from string. + +### Synchronously reading required lists of string values + +Synchronously gets a required array of config values for the given config key, converting from strings. + +### Synchronously reading Boolean values + +### Synchronously reading required Boolean values + +### Synchronously reading lists of Boolean values + +### Synchronously reading required lists of Boolean values + +### Synchronously reading integer values + +### Synchronously reading required integer values + +### Synchronously reading lists of integer values + +### Synchronously reading required lists of integer values + +### Synchronously reading double values + +### Synchronously reading required double values + +### Synchronously reading lists of double values + +### Synchronously reading required lists of double values + +### Synchronously reading bytes + +### Synchronously reading required bytes + +### Synchronously reading collections of byte chunks + +### Synchronously reading required collections of byte chunks + +## See Also + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +- Synchronously reading values +- Topics +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.intArray(\_:) + +Case + +# ConfigContent.intArray(\_:) + +An array of integer values. + +case intArray([Int]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/foundation/url + +- Configuration +- Foundation +- URL + +Extended Structure + +# URL + +ConfigurationFoundation + +extension URL + +## Topics + +## Relationships + +### Conforms To + +- `ExpressibleByConfigString` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` + +- URL +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/appending(_:) + +#app-main) + +- Configuration +- AbsoluteConfigKey +- appending(\_:) + +Instance Method + +# appending(\_:) + +Returns a new absolute configuration key by appending the given relative key. + +ConfigKey.swift + +## Parameters + +`relative` + +The relative configuration key to append to this key. + +## Return Value + +A new absolute configuration key with the relative key appended. + +- appending(\_:) +- Parameters +- Return Value + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/init(_:issecret:) + +#app-main) + +- Configuration +- ConfigValue +- init(\_:isSecret:) + +Initializer + +# init(\_:isSecret:) + +Creates a new configuration value. + +init( +_ content: ConfigContent, +isSecret: Bool +) + +ConfigProvider.swift + +## Parameters + +`content` + +The configuration content. + +`isSecret` + +Whether the value contains sensitive information. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:issecret:default:fileid:line:) + +#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:isSecret:default:fileID:line:) + +Instance Method + +# string(forKey:isSecret:default:fileID:line:) + +Synchronously gets a config value for the given config key, with a default fallback. + +func string( +forKey key: ConfigKey, +isSecret: Bool = false, +default defaultValue: String, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`defaultValue` + +The fallback value returned when the config value is missing or invalid. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The config value if found and convertible, otherwise the default value. + +## Discussion + +Use this method when you need a guaranteed non-nil result. If the configuration value is missing or can’t be converted to the expected type, the default value is returned instead. + +let maxRetries = snapshot.int(forKey: ["network", "maxRetries"], default: 3) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, converting from string. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:isSecret:default:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/systempackage/filepath + +- Configuration +- SystemPackage +- FilePath + +Extended Structure + +# FilePath + +ConfigurationSystemPackage + +extension FilePath + +## Topics + +## Relationships + +### Conforms To + +- `ExpressibleByConfigString` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` + +- FilePath +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/expressiblebyconfigstring/init(configstring:) + +#app-main) + +- Configuration +- ExpressibleByConfigString +- init(configString:) + +Initializer + +# init(configString:) + +Creates an instance from a configuration string value. + +init?(configString: String) + +ExpressibleByConfigString.swift + +**Required** + +## Parameters + +`configString` + +The string value from the configuration provider. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/metadata-swift.struct + +- Configuration +- AccessEvent +- AccessEvent.Metadata + +Structure + +# AccessEvent.Metadata + +Metadata describing the configuration access operation. + +struct Metadata + +AccessReporter.swift + +## Overview + +Contains information about the type of access, the key accessed, value type, source location, and timestamp. + +## Topics + +### Creating access event metadata + +`init(accessKind: AccessEvent.Metadata.AccessKind, key: AbsoluteConfigKey, valueType: ConfigType, sourceLocation: AccessEvent.Metadata.SourceLocation, accessTimestamp: Date)` + +Creates access event metadata. + +`enum AccessKind` + +The type of configuration access operation. + +### Inspecting access event metadata + +`var accessKind: AccessEvent.Metadata.AccessKind` + +The type of configuration access operation for this event. + +`var accessTimestamp: Date` + +The timestamp when the configuration access occurred. + +`var key: AbsoluteConfigKey` + +The configuration key accessed. + +`var sourceLocation: AccessEvent.Metadata.SourceLocation` + +The source code location where the access occurred. + +`var valueType: ConfigType` + +The expected type of the configuration value. + +### Structures + +`struct SourceLocation` + +The source code location where a configuration access occurred. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating an access event + +Creates a configuration access event. + +`struct ProviderResult` + +The result of a configuration lookup from a specific provider. + +- AccessEvent.Metadata +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/broadcastingaccessreporter/init(upstreams:) + +#app-main) + +- Configuration +- BroadcastingAccessReporter +- init(upstreams:) + +Initializer + +# init(upstreams:) + +Creates a new broadcasting access reporter. + +init(upstreams: [any AccessReporter]) + +AccessReporter.swift + +## Parameters + +`upstreams` + +The reporters that will receive forwarded events. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/expressiblebyextendedgraphemeclusterliteral-implementations + +- Configuration +- ConfigValue +- ExpressibleByExtendedGraphemeClusterLiteral Implementations + +API Collection + +# ExpressibleByExtendedGraphemeClusterLiteral Implementations + +## Topics + +### Initializers + +`init(unicodeScalarLiteral: Self.ExtendedGraphemeClusterLiteralType)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/customstringconvertible-implementations + +- Configuration +- ConfigContextValue +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/init(provider:accessreporter:) + +#app-main) + +- Configuration +- ConfigReader +- init(provider:accessReporter:) + +Initializer + +# init(provider:accessReporter:) + +Creates a config reader with a single provider. + +init( +provider: some ConfigProvider, +accessReporter: (any AccessReporter)? = nil +) + +ConfigReader.swift + +## Parameters + +`provider` + +The configuration provider. + +`accessReporter` + +The reporter for configuration access events. + +## See Also + +### Creating config readers + +[`init(providers: [any ConfigProvider], accessReporter: (any AccessReporter)?)`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/init(providers:accessreporter:)) + +Creates a config reader with multiple providers. + +- init(provider:accessReporter:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/prepending(_:) + +# An unknown error occurred. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/int + +- Configuration +- ConfigType +- ConfigType.int + +Case + +# ConfigType.int + +An integer value. + +case int + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/equatable-implementations + +- Configuration +- ConfigValue +- Equatable Implementations + +API Collection + +# Equatable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/string(_:) + +#app-main) + +- Configuration +- ConfigContextValue +- ConfigContextValue.string(\_:) + +Case + +# ConfigContextValue.string(\_:) + +A string value. + +case string(String) + +ConfigContext.swift + +## See Also + +### Configuration context values + +`case bool(Bool)` + +A Boolean value. + +`case int(Int)` + +An integer value. + +`case double(Double)` + +A floating point value. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/stringarray + +- Configuration +- ConfigType +- ConfigType.stringArray + +Case + +# ConfigType.stringArray + +An array of string values. + +case stringArray + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/stringarray(forkey:issecret:fileid:line:) + +#app-main) + +- Configuration +- ConfigSnapshotReader +- stringArray(forKey:isSecret:fileID:line:) + +Instance Method + +# stringArray(forKey:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key. + +func stringArray( +forKey key: ConfigKey, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve optional configuration values from a snapshot. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let port = snapshot.int(forKey: ["server", "port"]) + +## See Also + +### Synchronously reading lists of string values + +Synchronously gets an array of config values for the given config key, converting from strings. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets an array of config values for the given config key with default fallback, converting from strings. + +- stringArray(forKey:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/components + +- Configuration +- AbsoluteConfigKey +- components + +Instance Property + +# components + +The hierarchical components that make up this absolute configuration key. + +var components: [String] + +ConfigKey.swift + +## Discussion + +Each component represents a level in the configuration hierarchy, forming a complete path from the root of the configuration structure. + +## See Also + +### Inspecting an absolute configuration key + +[`var context: [String : ConfigContextValue]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/context) + +Additional context information for this configuration key. + +- components +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/bool + +- Configuration +- ConfigType +- ConfigType.bool + +Case + +# ConfigType.bool + +A Boolean value. + +case bool + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/dynamic(_:) + +#app-main) + +- Configuration +- SecretsSpecifier +- SecretsSpecifier.dynamic(\_:) + +Case + +# SecretsSpecifier.dynamic(\_:) + +The library determines the secret status dynamically by evaluating each key-value pair. + +SecretsSpecifier.swift + +## Parameters + +`closure` + +A closure that takes a key and value and returns whether the value should be treated as secret. + +## Discussion + +Use this case when you need complex logic to determine whether a value is secret based on the key name, value content, or other criteria. + +## See Also + +### Types of specifiers + +`case all` + +The library treats all configuration values as secrets. + +The library treats the specified keys as secrets. + +`case none` + +The library treats no configuration values as secrets. + +- SecretsSpecifier.dynamic(\_:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/all + +- Configuration +- SecretsSpecifier +- SecretsSpecifier.all + +Case + +# SecretsSpecifier.all + +The library treats all configuration values as secrets. + +case all + +SecretsSpecifier.swift + +## Discussion + +Use this case when the provider exclusively handles sensitive information and all values should be protected from disclosure. + +## See Also + +### Types of specifiers + +The library treats the specified keys as secrets. + +The library determines the secret status dynamically by evaluating each key-value pair. + +`case none` + +The library treats no configuration values as secrets. + +- SecretsSpecifier.all +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting/providercompattest/testconfiguration + +- ConfigurationTesting +- ProviderCompatTest +- ProviderCompatTest.TestConfiguration + +Structure + +# ProviderCompatTest.TestConfiguration + +Configuration options for customizing test behavior. + +struct TestConfiguration + +ProviderCompatTest.swift + +## Topics + +### Initializers + +[`init(overrides: [String : ConfigContent])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting/providercompattest/testconfiguration/init(overrides:)) + +Creates a new test configuration. + +### Instance Properties + +[`var overrides: [String : ConfigContent]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting/providercompattest/testconfiguration/overrides) + +Value overrides for testing custom scenarios. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +- ProviderCompatTest.TestConfiguration +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/foundation/date + +- Configuration +- Foundation +- Date + +Extended Structure + +# Date + +ConfigurationFoundation + +extension Date + +## Topics + +## Relationships + +### Conforms To + +- `ExpressibleByConfigString` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` + +- Date +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/boolarray + +- Configuration +- ConfigType +- ConfigType.boolArray + +Case + +# ConfigType.boolArray + +An array of Boolean values. + +case boolArray + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/equatable-implementations + +- Configuration +- ConfigContextValue +- Equatable Implementations + +API Collection + +# Equatable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/init(_:) + +#app-main) + +- Configuration +- AbsoluteConfigKey +- init(\_:) + +Initializer + +# init(\_:) + +Creates a new absolute configuration key from a relative key. + +init(_ relative: ConfigKey) + +ConfigKey.swift + +## Parameters + +`relative` + +The relative configuration key to convert. + +## See Also + +### Creating an absolute configuration key + +[`init([String], context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/init(_:context:)) + +Creates a new absolute configuration key. + +- init(\_:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/asyncsequence-implementations + +- Configuration +- ConfigUpdatesAsyncSequence +- AsyncSequence Implementations + +API Collection + +# AsyncSequence Implementations + +## Topics + +### Instance Methods + +[`func chunked(by: AsyncTimerSequence) -> AsyncChunksOfCountOrSignalSequence>`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/chunked(by:)-trjw) + +`func chunked(by: AsyncTimerSequence, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence>` + +[`func chunks(ofCount: Int, or: AsyncTimerSequence) -> AsyncChunksOfCountOrSignalSequence>`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/chunks(ofcount:or:)-8u4c4) + +`func chunks(ofCount: Int, or: AsyncTimerSequence, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence>` + +`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/makeasynciterator()) + +`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/share(bufferingpolicy:)) + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshot/value(forkey:type:) + +#app-main) + +- Configuration +- ConfigSnapshot +- value(forKey:type:) + +Instance Method + +# value(forKey:type:) + +Returns a value for the specified key from this immutable snapshot. + +func value( +forKey key: AbsoluteConfigKey, +type: ConfigType + +ConfigProvider.swift + +**Required** + +## Parameters + +`key` + +The configuration key to look up. + +`type` + +The expected configuration value type. + +## Return Value + +The lookup result containing the value and encoded key, or nil if not found. + +## Discussion + +Unlike `value(forKey:type:)`, this method always returns the same value for identical parameters because the snapshot represents a fixed point in time. Values can be accessed synchronously and efficiently. + +## See Also + +### Required methods + +`var providerName: String` + +The human-readable name of the configuration provider that created this snapshot. + +- value(forKey:type:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/intarray + +- Configuration +- ConfigType +- ConfigType.intArray + +Case + +# ConfigType.intArray + +An array of integer values. + +case intArray + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/double(_:) + +#app-main) + +- Configuration +- ConfigContextValue +- ConfigContextValue.double(\_:) + +Case + +# ConfigContextValue.double(\_:) + +A floating point value. + +case double(Double) + +ConfigContext.swift + +## See Also + +### Configuration context values + +`case string(String)` + +A string value. + +`case bool(Bool)` + +A Boolean value. + +`case int(Int)` + +An integer value. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider/customstringconvertible-implementations + +- Configuration +- DirectoryFilesProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/int(_:) + +#app-main) + +- Configuration +- ConfigContextValue +- ConfigContextValue.int(\_:) + +Case + +# ConfigContextValue.int(\_:) + +An integer value. + +case int(Int) + +ConfigContext.swift + +## See Also + +### Configuration context values + +`case string(String)` + +A string value. + +`case bool(Bool)` + +A Boolean value. + +`case double(Double)` + +A floating point value. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/comparable-implementations + +- Configuration +- AbsoluteConfigKey +- Comparable Implementations + +API Collection + +# Comparable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/init(providers:accessreporter:) + +#app-main) + +- Configuration +- ConfigReader +- init(providers:accessReporter:) + +Initializer + +# init(providers:accessReporter:) + +Creates a config reader with multiple providers. + +init( +providers: [any ConfigProvider], +accessReporter: (any AccessReporter)? = nil +) + +ConfigReader.swift + +## Parameters + +`providers` + +The configuration providers, queried in order until a value is found. + +`accessReporter` + +The reporter for configuration access events. + +## See Also + +### Creating config readers + +`init(provider: some ConfigProvider, accessReporter: (any AccessReporter)?)` + +Creates a config reader with a single provider. + +- init(providers:accessReporter:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:as:issecret:default:fileid:line:)-fzpe + +-fzpe#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:as:isSecret:default:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:default:fileID:line:) + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +default defaultValue: Value, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`defaultValue` + +The fallback value returned when the config value is missing or invalid. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The config value if found and convertible, otherwise the default value. + +## Discussion + +Use this method when you need a guaranteed non-nil result for string-convertible types. If the configuration value is missing or can’t be converted to the expected type, the default value is returned instead. + +let serverMode = snapshot.string(forKey: ["server", "mode"], as: ServerMode.self, default: .production) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +- string(forKey:as:isSecret:default:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/init(_:context:) + +#app-main) + +- Configuration +- AbsoluteConfigKey +- init(\_:context:) + +Initializer + +# init(\_:context:) + +Creates a new absolute configuration key. + +init( +_ components: [String], +context: [String : ConfigContextValue] = [:] +) + +ConfigKey.swift + +## Parameters + +`components` + +The hierarchical components that make up the complete key path. + +`context` + +Additional context information for the key. + +## See Also + +### Creating an absolute configuration key + +`init(ConfigKey)` + +Creates a new absolute configuration key from a relative key. + +- init(\_:context:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/customdebugstringconvertible-implementations + +- Configuration +- MutableInMemoryProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresult + +- Configuration +- AccessEvent +- AccessEvent.ProviderResult + +Structure + +# AccessEvent.ProviderResult + +The result of a configuration lookup from a specific provider. + +struct ProviderResult + +AccessReporter.swift + +## Overview + +Contains the provider’s name and the outcome of querying that provider, which can be either a successful lookup result or an error. + +## Topics + +### Creating provider results + +Creates a provider result. + +### Inspecting provider results + +The outcome of the configuration lookup operation. + +`var providerName: String` + +The name of the configuration provider that processed the lookup. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating an access event + +Creates a configuration access event. + +`struct Metadata` + +Metadata describing the configuration access operation. + +- AccessEvent.ProviderResult +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:issecret:fileid:line:) + +#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:isSecret:fileID:line:) + +Instance Method + +# string(forKey:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key. + +func string( +forKey key: ConfigKey, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve optional configuration values from a snapshot. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let port = snapshot.int(forKey: ["server", "port"]) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/double(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.double(\_:) + +Case + +# ConfigContent.double(\_:) + +A double value. + +case double(Double) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.stringArray(\_:) + +Case + +# ConfigContent.stringArray(\_:) + +An array of string values. + +case stringArray([String]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:as:issecret:fileid:line:)-8hlcf + +-8hlcf#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:as:isSecret:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve configuration values that can be converted from strings, such as custom types conforming to string conversion protocols. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let serverMode = snapshot.string(forKey: ["server", "mode"], as: ServerMode.self) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:as:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider/customdebugstringconvertible-implementations + +- Configuration +- DirectoryFilesProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshot/providername + +- Configuration +- ConfigSnapshot +- providerName + +Instance Property + +# providerName + +The human-readable name of the configuration provider that created this snapshot. + +var providerName: String { get } + +ConfigProvider.swift + +**Required** + +## Discussion + +Used by `AccessReporter` and when diagnostic logging the config reader types. + +## See Also + +### Required methods + +Returns a value for the specified key from this immutable snapshot. + +- providerName +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/issecret(key:value:) + +#app-main) + +- Configuration +- SecretsSpecifier +- isSecret(key:value:) + +Instance Method + +# isSecret(key:value:) + +Determines whether a configuration value should be treated as secret. + +func isSecret( +key: KeyType, +value: ValueType + +SecretsSpecifier.swift + +Available when `KeyType` conforms to `Hashable`, `KeyType` conforms to `Sendable`, and `ValueType` conforms to `Sendable`. + +## Parameters + +`key` + +The provider-specific configuration key. + +`value` + +The configuration value to evaluate. + +## Return Value + +`true` if the value should be treated as secret; otherwise, `false`. + +## Discussion + +This method evaluates the secrets specifier against the provided key-value pair to determine if the value contains sensitive information that should be protected from disclosure. + +let isSecret = specifier.isSecret(key: "API_KEY", value: "secret123") +// Returns: true + +- isSecret(key:value:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/metadata-swift.property + +- Configuration +- AccessEvent +- metadata + +Instance Property + +# metadata + +Metadata that describes the configuration access operation. + +var metadata: AccessEvent.Metadata + +AccessReporter.swift + +## See Also + +### Inspecting an access event + +The final outcome of the configuration access operation. + +`var conversionError: (any Error)?` + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +[`var providerResults: [AccessEvent.ProviderResult]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresults) + +The results from each configuration provider that was queried. + +| +| + +--- + diff --git a/Examples/BushelCloud/.claude/implementation-patterns.md b/Examples/BushelCloud/.claude/implementation-patterns.md new file mode 100644 index 00000000..82085430 --- /dev/null +++ b/Examples/BushelCloud/.claude/implementation-patterns.md @@ -0,0 +1,384 @@ +# Implementation History and Patterns + +> **Note**: This is a detailed reference guide documenting implementation decisions, patterns, and lessons learned. For daily development, see the main [CLAUDE.md](../CLAUDE.md) file. + +This document covers key implementation decisions, patterns, and lessons learned during BushelCloud development. Use this as reference when building similar CloudKit demos. + +## Data Source Integration Pattern + +BushelCloud integrates multiple external data sources. Here's the pattern for adding new sources: + +**Step 1: Create Fetcher** +```swift +struct AppleDBFetcher: Sendable { + func fetch() async throws -> [RestoreImageRecord] { + // 1. Fetch data from external API + // 2. Parse and map to domain model + // 3. Return array of records + } +} +``` + +**Step 2: Add to Pipeline** +```swift +// In DataSourcePipeline.swift +private func fetchRestoreImages(options: Options) async throws -> [RestoreImageRecord] { + async let ipswImages = IPSWFetcher().fetch() + async let appleDBImages = AppleDBFetcher().fetch() + + var allImages: [RestoreImageRecord] = [] + allImages.append(contentsOf: try await ipswImages) + allImages.append(contentsOf: try await appleDBImages) + + return deduplicateRestoreImages(allImages) +} +``` + +**Step 3: Add CLI Option (Optional)** +```swift +struct SyncCommand { + @Flag(name: .long, help: "Exclude AppleDB.dev as data source") + var noAppleDB: Bool = false + + private func buildSyncOptions() -> SyncEngine.SyncOptions { + var pipelineOptions = DataSourcePipeline.Options() + if noAppleDB { + pipelineOptions.includeAppleDB = false + } + return pipelineOptions + } +} +``` + +## Deduplication Strategy + +**Build Number as Unique Key:** + +Multiple sources provide the same restore images. BushelCloud uses `buildNumber` as the unique key: + +```swift +private func deduplicateRestoreImages(_ images: [RestoreImageRecord]) -> [RestoreImageRecord] { + var uniqueImages: [String: RestoreImageRecord] = [:] + + for image in images { + let key = image.buildNumber + + if let existing = uniqueImages[key] { + // Merge records, prefer most complete data + uniqueImages[key] = mergeRestoreImages(existing, image) + } else { + uniqueImages[key] = image + } + } + + return Array(uniqueImages.values) + .sorted { $0.releaseDate > $1.releaseDate } +} +``` + +**Merge Priority Rules:** +1. **IPSW.me** - Most complete data (has both SHA1 + SHA256) +2. **AppleDB** - Device-specific signing status, comprehensive coverage +3. **MESU** - Authoritative for signing status (freshness detection) +4. **MrMacintosh** - Beta/RC releases, community-maintained + +**Merge Logic:** +```swift +private func mergeRestoreImages( + _ existing: RestoreImageRecord, + _ new: RestoreImageRecord +) -> RestoreImageRecord { + var merged = existing + + // Prefer more recent sourceUpdatedAt + if new.sourceUpdatedAt > existing.sourceUpdatedAt { + merged = new + } + + // Backfill missing SHA hashes + if merged.sha256Hash.isEmpty && !new.sha256Hash.isEmpty { + merged.sha256Hash = new.sha256Hash + } + if merged.sha1Hash.isEmpty && !new.sha1Hash.isEmpty { + merged.sha1Hash = new.sha1Hash + } + + // MESU is authoritative for signing status + if new.source == "MESU" { + merged.isSigned = new.isSigned + } + + return merged +} +``` + +## AppleDB Integration + +AppleDB was added to provide comprehensive restore image data with device-specific signing status. + +**API Endpoint:** +```swift +let url = URL(string: "https://api.appledb.dev/ios/VirtualMac2,1.json")! +``` + +**Key Features:** +- Device filtering for VirtualMac variants +- File size parsing (string → Int64) +- Prerelease detection (beta/RC in version string) +- Signing status per device + +**Implementation Files:** +- `AppleDB/AppleDBParser.swift` - API client +- `AppleDB/AppleDBFetcher.swift` - Fetcher pattern implementation +- `AppleDB/Models/AppleDBVersion.swift` - Domain model +- `AppleDB/Models/AppleDBAPITypes.swift` - API response types + +## Server-to-Server Authentication Migration + +BushelCloud was refactored from API Tokens to S2S Keys to demonstrate enterprise authentication patterns. + +**What Changed:** + +| Before (API Token) | After (S2S Key) | +|-------------------|-----------------| +| Single token string | Key ID + Private Key (.pem) | +| `APITokenManager` | `ServerToServerAuthManager` | +| `CLOUDKIT_API_TOKEN` env var | `CLOUDKIT_KEY_ID` + `CLOUDKIT_KEY_FILE` | +| `--api-token` flag | `--key-id` + `--key-file` flags | + +**Migration Steps:** +1. Generate ECDSA key pair with OpenSSL +2. Register public key in CloudKit Dashboard +3. Update `BushelCloudKitService` to use `ServerToServerAuthManager` +4. Update all commands to accept new parameters +5. Update environment variable handling +6. Update documentation + +## Critical Issues Solved + +### Issue 1: CloudKit Schema Validation Errors + +**Problem:** `cktool validate-schema` failed with parsing error. + +**Root Cause:** Schema file missing `DEFINE SCHEMA` header and included system fields. + +**Solution:** +```text +# Wrong +RECORD TYPE RestoreImage ( + "__recordID" RECORD ID, ❌ + +# Correct +DEFINE SCHEMA ✅ + +RECORD TYPE RestoreImage ( + "version" STRING, ✅ +``` + +**Lesson:** CloudKit auto-adds system fields. Never include them in schema definitions. + +### Issue 2: ACCESS_DENIED Errors Despite Correct Permissions + +**Problem:** Record creation failed with ACCESS_DENIED even after adding `_creator` permissions. + +**Root Cause:** Schema needed BOTH `_creator` AND `_icloud` permissions. + +**Solution:** +```text +GRANT READ, CREATE, WRITE TO "_creator", +GRANT READ, CREATE, WRITE TO "_icloud", ← Both required! +``` + +**Lesson:** S2S authentication with public database requires permissions for both roles. + +### Issue 3: cktool Command Syntax Confusion + +**Problem:** Script used invalid cktool commands and flags. + +**Incorrect:** +```bash +xcrun cktool list-containers # ❌ Not a valid command +xcrun cktool validate-schema schema.ckdb # ❌ Missing --file flag +``` + +**Correct:** +```bash +xcrun cktool get-teams # ✅ Valid auth test command +xcrun cktool validate-schema --file schema.ckdb # ✅ Correct syntax +``` + +**Lesson:** Always check cktool syntax with `xcrun cktool --help`. + +## Token Types Reference + +CloudKit uses different tokens for different operations: + +| Token Type | Purpose | Used By | How to Get | +|-----------|---------|---------|------------| +| **Management Token** | Schema operations (import/export) | `cktool` | Dashboard → CloudKit Web Services | +| **Server-to-Server Key** | Runtime API operations (server-side) | Your application | Dashboard → Server-to-Server Keys | +| **API Token** | Simpler runtime auth (legacy) | Legacy apps | Dashboard → API Tokens | +| **User Token** | User-specific operations | Web apps | OAuth flow | + +**For BushelCloud:** +- Schema setup: **Management Token** (via `cktool save-token`) +- Sync/export: **Server-to-Server Key** (Key ID + .pem file) + +## Date Handling with CloudKit + +CloudKit dates use **milliseconds since epoch**, not seconds: + +```swift +// MistKit handles conversion automatically +fields["releaseDate"] = .date(Date()) // ✅ Converted to milliseconds + +// If manually creating timestamp +let milliseconds = Int64(date.timeIntervalSince1970 * 1000) +fields["releaseDate"] = .int64(milliseconds) +``` + +## Boolean Fields in CloudKit + +CloudKit has no native boolean type. Use INT64 with 0/1: + +**Schema:** +```text +"isSigned" INT64 QUERYABLE, +"isPrerelease" INT64 QUERYABLE, +``` + +**Swift code:** +```swift +fields["isSigned"] = .int64(record.isSigned ? 1 : 0) +fields["isPrerelease"] = .int64(record.isPrerelease ? 1 : 0) +``` + +**Reading back:** +```swift +if case .int64(let value) = fields["isSigned"] { + let isSigned = value == 1 +} +``` + +## Batch Operation Optimization + +CloudKit limits: **200 operations per request** + +**Efficient batching:** +```swift +let batchSize = 200 +let batches = operations.chunked(into: batchSize) + +for (index, batch) in batches.enumerated() { + print("Batch \(index + 1)/\(batches.count)...") + let results = try await service.modifyRecords(batch) + + // Process results immediately + for result in results { + if result.recordType == "Unknown" { + // Handle error + } + } +} +``` + +**Don't batch too small:** Each request has overhead. Use full 200-operation batches when possible. + +## Reference Field Ordering + +Upload order matters for records with references: + +```text +SwiftVersion (no dependencies) + ↓ +RestoreImage (no dependencies) + ↓ +XcodeVersion (references both) +``` + +**Correct upload order:** +```swift +// 1. Records with no dependencies first +try await syncSwiftVersions() +try await syncRestoreImages() + +// 2. Records with references last +try await syncXcodeVersions() // References uploaded records +``` + +**Wrong order causes:** `VALIDATING_REFERENCE_ERROR` + +## Error Handling Best Practices + +**Check for partial failures:** +```swift +let results = try await service.modifyRecords(batch) +let errors = results.filter { $0.recordType == "Unknown" } + +if !errors.isEmpty { + for error in errors { + print("Failed: \(error.recordName ?? "unknown")") + print("Reason: \(error.reason ?? "N/A")") + } +} +``` + +**Common recoverable errors:** +- `VALIDATING_REFERENCE_ERROR` - Retry after uploading referenced records +- `CONFLICT` - Use `.forceReplace` instead of `.create` +- `QUOTA_EXCEEDED` - Reduce batch size or wait + +**Non-recoverable errors:** +- `ACCESS_DENIED` - Fix schema permissions +- `AUTHENTICATION_FAILED` - Fix key ID/PEM file + +## Lessons for Future Demos + +When building similar CloudKit demos (e.g., Celestra): + +**1. Start with S2S Keys from the beginning** +- More secure and production-ready +- Better demonstrates enterprise patterns + +**2. Schema setup first** +- Create schema with `DEFINE SCHEMA` header +- Include both `_creator` and `_icloud` permissions +- Test with cktool before app development + +**3. Use the DataSourcePipeline pattern** +- Parallel fetching with `async let` +- Deduplication by unique key +- Merge priority rules for conflict resolution + +**4. Reusable patterns from BushelCloud:** +- `BushelCloudKitService` wrapper pattern +- `CloudKitRecord` protocol for models +- CLI structure with swift-argument-parser +- Environment variable handling +- Batch operation chunking + +**5. Documentation structure:** +- README for user-facing quick start +- CLAUDE.md for development context +- DocC for comprehensive tutorials +- No separate Documentation/ directory + +## Common Pitfalls to Avoid + +**❌ Don't:** +- Commit .pem files to git +- Use system fields in schema +- Grant permissions to only one role +- Upload references before referenced records +- Batch operations larger than 200 +- Assume boolean type exists in CloudKit +- Use seconds for timestamps (use milliseconds) + +**✅ Do:** +- Use environment variables for credentials +- Start schema with `DEFINE SCHEMA` +- Grant to both `_creator` and `_icloud` +- Upload in dependency order +- Chunk operations to 200 max +- Use INT64 (0/1) for booleans +- Let MistKit handle date conversion diff --git a/Examples/BushelCloud/.claude/migration-to-bushelkit.md b/Examples/BushelCloud/.claude/migration-to-bushelkit.md new file mode 100644 index 00000000..3a6b290d --- /dev/null +++ b/Examples/BushelCloud/.claude/migration-to-bushelkit.md @@ -0,0 +1,918 @@ +# Migration Plan: BushelCloudKit to BushelKit (Gradual Migration Strategy) + +## Overview + +Gradually migrate code from `Sources/BushelCloudKit` to BushelKit package using a safe, incremental approach: + +1. **Isolate** non-MistKit code into a separate target (`BushelCloudData`) +2. **Deprecate** the isolated code to signal upcoming migration +3. **Migrate** to BushelKit incrementally as code stabilizes +4. **Update** BushelCloud periodically as dependencies shift + +This approach minimizes risk, maintains working code throughout, and allows for iterative testing. + +## Architecture Strategy + +**Key Principle:** Gradual migration with deprecation warnings and intermediate targets + +### Phase 1: Current State +``` +BushelCloud/Sources/BushelCloudKit/ +├── Models/* (with MistKit FieldValue/RecordInfo) +├── DataSources/* (fetchers + pipeline) +├── CloudKit/* (service + sync) +└── Utilities/* +``` + +### Phase 2: Intermediate (Isolation) +``` +BushelCloud/Sources/ +├── BushelCloudKit/ (MistKit-dependent) +│ ├── CloudKit/* (service + sync) +│ └── Extensions/* (CloudKitRecord) +│ +└── BushelCloudData/ (NEW - MistKit-independent, DEPRECATED) + ├── Models/* (plain structs) + ├── DataSources/* (fetchers) + └── Utilities/* +``` + +### Phase 3: Final State (After Migration) +``` +BushelKit/Sources/ +├── BushelFoundation/ (models from BushelCloudData) +├── BushelHub/ (fetchers from BushelCloudData) +└── BushelUtilities/ (utilities from BushelCloudData) + +BushelCloud/Sources/BushelCloudKit/ +├── CloudKit/* (service + sync + errors) +└── Extensions/* (CloudKitRecord extensions) +``` + +## What Moves Where + +### To BushelKit/BushelFoundation (Core Models & Configuration) + +**Plain Swift models (remove MistKit dependencies):** +- `Models/RestoreImageRecord.swift` → `BushelFoundation/RestoreImageRecord.swift` + - Remove: `toCloudKitFields()`, `from(recordInfo:)`, `formatForDisplay()` + - Keep: Core properties as plain Swift struct with Codable + +- `Models/XcodeVersionRecord.swift` → `BushelFoundation/XcodeVersionRecord.swift` + - Remove: CloudKit-specific methods + - Keep: Core properties, references as plain String fields + +- `Models/SwiftVersionRecord.swift` → `BushelFoundation/SwiftVersionRecord.swift` + - Remove: CloudKit-specific methods + - Keep: Core properties + +- `Models/DataSourceMetadata.swift` → `BushelFoundation/DataSourceMetadata.swift` + - Remove: CloudKit-specific methods + - Keep: Core metadata tracking properties + +**Configuration:** +- `Configuration/FetchConfiguration.swift` → `BushelFoundation/FetchConfiguration.swift` + - No changes needed (no MistKit dependency) + +### To BushelKit/BushelHub (Data Fetching) + +**Protocols & Infrastructure:** +- `DataSources/DataSourceFetcher.swift` → `BushelHub/DataSourceFetcher.swift` +- `DataSources/HTTPHeaderHelpers.swift` → `BushelHub/HTTPHeaderHelpers.swift` + +**Orchestration:** +- `DataSources/DataSourcePipeline.swift` → `BushelHub/DataSourcePipeline.swift` + - Update imports to use BushelFoundation models + +**Individual Fetchers:** +- `DataSources/IPSWFetcher.swift` → `BushelHub/Fetchers/IPSWFetcher.swift` +- `DataSources/AppleDBFetcher.swift` → `BushelHub/Fetchers/AppleDBFetcher.swift` +- `DataSources/AppleDB/*.swift` (9 files) → `BushelHub/Fetchers/AppleDB/` +- `DataSources/XcodeReleasesFetcher.swift` → `BushelHub/Fetchers/XcodeReleasesFetcher.swift` +- `DataSources/SwiftVersionFetcher.swift` → `BushelHub/Fetchers/SwiftVersionFetcher.swift` +- `DataSources/MESUFetcher.swift` → `BushelHub/Fetchers/MESUFetcher.swift` +- `DataSources/MrMacintoshFetcher.swift` → `BushelHub/Fetchers/MrMacintoshFetcher.swift` +- `DataSources/TheAppleWikiFetcher.swift` → `BushelHub/Fetchers/TheAppleWikiFetcher.swift` +- `DataSources/TheAppleWiki/*.swift` (4 files) → `BushelHub/Fetchers/TheAppleWiki/` + +### To BushelKit/BushelUtilities (Utilities) + +- `Utilities/FormattingHelpers.swift` → `BushelUtilities/FormattingHelpers.swift` +- `Utilities/ConsoleOutput.swift` → `BushelUtilities/ConsoleOutput.swift` + +### Stay in BushelCloud (CloudKit Integration) + +**CloudKit Service Layer:** +- `CloudKit/BushelCloudKitService.swift` (KEEP - requires MistKit) +- `CloudKit/SyncEngine.swift` (KEEP - requires MistKit) +- `CloudKit/RecordManaging+Query.swift` (KEEP - extends MistKit) +- `CloudKit/BushelCloudKitError.swift` (KEEP - service errors) + +**New CloudKitRecord Extensions:** +- Create `Extensions/RestoreImageRecord+CloudKit.swift` + - Add: `CloudKitRecord` protocol conformance + - Add: `toCloudKitFields()`, `from(recordInfo:)`, `formatForDisplay()` + - Import: MistKit, BushelFoundation + +- Create `Extensions/XcodeVersionRecord+CloudKit.swift` +- Create `Extensions/SwiftVersionRecord+CloudKit.swift` +- Create `Extensions/DataSourceMetadata+CloudKit.swift` + +**CLI Layer:** +- `BushelCloudCLI/` (KEEP - all CLI commands) + +## Phase 1: Create BushelCloudData Target (Week 1) + +**Goal:** Isolate non-MistKit code into a separate target within BushelCloud + +### 1.1 Create New Target in Package.swift + +Add new `BushelCloudData` target in `/Users/leo/Documents/Projects/BushelCloud/Package.swift`: + +```swift +targets: [ + // Existing BushelCloudKit target (will be slimmed down) + .target( + name: "BushelCloudKit", + dependencies: [ + .product(name: "MistKit", package: "MistKit"), + .product(name: "BushelLogging", package: "BushelKit"), + .target(name: "BushelCloudData"), // NEW dependency + ], + swiftSettings: swiftSettings + ), + + // NEW target - MistKit-independent code + .target( + name: "BushelCloudData", + dependencies: [ + .product(name: "IPSWDownloads", package: "IPSWDownloads"), + .product(name: "SwiftSoup", package: "SwiftSoup"), + .product(name: "BushelLogging", package: "BushelKit"), + // NO MistKit dependency + ], + swiftSettings: swiftSettings + ), + + .executableTarget( + name: "BushelCloudCLI", + dependencies: [ + .target(name: "BushelCloudKit"), + .target(name: "BushelCloudData"), // NEW dependency + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + swiftSettings: swiftSettings + ), +] +``` + +### 1.2 Create BushelCloudData Directory Structure + +```bash +mkdir -p Sources/BushelCloudData/Models +mkdir -p Sources/BushelCloudData/DataSources/AppleDB +mkdir -p Sources/BushelCloudData/DataSources/TheAppleWiki +mkdir -p Sources/BushelCloudData/Utilities +mkdir -p Sources/BushelCloudData/Configuration +``` + +### 1.3 Move Models to BushelCloudData (Remove MistKit) + +Copy and refactor each model to remove MistKit dependencies: + +**Example: RestoreImageRecord.swift** + +Move from `BushelCloudKit/Models/` to `BushelCloudData/Models/`, removing CloudKit methods: + +```swift +// Sources/BushelCloudData/Models/RestoreImageRecord.swift +import Foundation + +@available(*, deprecated, message: "This type will move to BushelKit in a future release") +public struct RestoreImageRecord: Codable, Sendable { + public let version: String + public let buildNumber: String + public let releaseDate: Date + public let downloadURL: URL + public let fileSize: UInt64? + public let sha256Hash: String? + public let sha1Hash: String? + public let isSigned: Bool? + public let isPrerelease: Bool + public let source: String + public let notes: String? + public let sourceUpdatedAt: Date? + + public init( + version: String, + buildNumber: String, + releaseDate: Date, + downloadURL: URL, + fileSize: UInt64? = nil, + sha256Hash: String? = nil, + sha1Hash: String? = nil, + isSigned: Bool? = nil, + isPrerelease: Bool = false, + source: String, + notes: String? = nil, + sourceUpdatedAt: Date? = nil + ) { + self.version = version + self.buildNumber = buildNumber + self.releaseDate = releaseDate + self.downloadURL = downloadURL + self.fileSize = fileSize + self.sha256Hash = sha256Hash + self.sha1Hash = sha1Hash + self.isSigned = isSigned + self.isPrerelease = isPrerelease + self.source = source + self.notes = notes + self.sourceUpdatedAt = sourceUpdatedAt + } +} +``` + +**Repeat for:** +- XcodeVersionRecord (references become String properties) +- SwiftVersionRecord +- DataSourceMetadata + +### 1.4 Move Data Fetchers to BushelCloudData + +Copy all fetchers to BushelCloudData, update imports: + +```swift +// Sources/BushelCloudData/DataSources/IPSWFetcher.swift +import Foundation +import BushelLogging +import BushelCloudData // For models + +@available(*, deprecated, message: "This type will move to BushelKit in a future release") +public struct IPSWFetcher: DataSourceFetcher { + // Implementation stays the same +} +``` + +**Files to move:** +- DataSourceFetcher.swift (protocol) +- DataSourcePipeline.swift +- HTTPHeaderHelpers.swift +- All individual fetchers (IPSWFetcher, AppleDBFetcher, etc.) +- AppleDB/*.swift (9 files) +- TheAppleWiki/*.swift (4 files) + +### 1.5 Move Utilities and Configuration + +```swift +// Sources/BushelCloudData/Utilities/FormattingHelpers.swift +@available(*, deprecated, message: "This type will move to BushelKit in a future release") +public enum FormattingHelpers { + // Implementation unchanged +} + +// Sources/BushelCloudData/Configuration/FetchConfiguration.swift +@available(*, deprecated, message: "This type will move to BushelKit in a future release") +public struct FetchConfiguration: Codable, Sendable { + // Implementation unchanged +} +``` + +### 1.6 Create CloudKitRecord Extensions in BushelCloudKit + +Create new `Extensions/` directory: + +```swift +// Sources/BushelCloudKit/CloudKitRecord.swift +import MistKit +import Foundation + +public protocol CloudKitRecord { + static var cloudKitRecordType: String { get } + func toCloudKitFields() -> [String: FieldValue] + static func from(recordInfo: RecordInfo) -> Self? + static func formatForDisplay(_ recordInfo: RecordInfo) -> String +} + +// Sources/BushelCloudKit/Extensions/RestoreImageRecord+CloudKit.swift +import MistKit +import BushelCloudData +import Foundation + +extension RestoreImageRecord: CloudKitRecord { + public static var cloudKitRecordType: String { "RestoreImage" } + + public func toCloudKitFields() -> [String: FieldValue] { + // CloudKit serialization logic (moved from original model) + } + + public static func from(recordInfo: RecordInfo) -> RestoreImageRecord? { + // CloudKit deserialization logic (moved from original model) + } + + public static func formatForDisplay(_ recordInfo: RecordInfo) -> String { + // Display formatting (moved from original model) + } +} +``` + +**Create extensions for:** +- RestoreImageRecord+CloudKit.swift +- XcodeVersionRecord+CloudKit.swift +- SwiftVersionRecord+CloudKit.swift +- DataSourceMetadata+CloudKit.swift + +### 1.7 Update Imports in BushelCloudKit + +Update CloudKit service files: + +```swift +// Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +import MistKit +import BushelCloudData // NEW - for models +import BushelLogging +import Foundation + +// Implementation unchanged - CloudKitRecord extensions auto-available + +// Sources/BushelCloudKit/CloudKit/SyncEngine.swift +import MistKit +import BushelCloudData // NEW - for models and DataSourcePipeline +import BushelLogging +import Foundation + +// Implementation unchanged +``` + +### 1.8 Test BushelCloud Build + +```bash +cd /Users/leo/Documents/Projects/BushelCloud +swift build +``` + +Verify: +- Both BushelCloudData and BushelCloudKit targets build +- Deprecation warnings appear for BushelCloudData types +- All CLI commands still work +- Tests pass + +### 1.9 Commit Phase 1 + +```bash +git add . +git commit -m "refactor: isolate non-MistKit code into BushelCloudData target + +- Create new BushelCloudData target (deprecated, will move to BushelKit) +- Move models, fetchers, utilities to BushelCloudData +- Add CloudKitRecord extensions in BushelCloudKit +- Update imports throughout codebase + +All functionality unchanged, preparing for gradual migration to BushelKit" +``` + +## Phase 2: Add Deprecation Warnings & Aliases (Week 2) + +**Goal:** Add clear deprecation messages and type aliases for smooth transition + +### 2.1 Add Detailed Deprecation Messages + +Update each type with specific migration guidance: + +```swift +@available(*, deprecated, renamed: "BushelKit.RestoreImageRecord", message: """ +This type is moving to BushelKit.BushelFoundation. +Update your imports: + Before: import BushelCloudData + After: import BushelFoundation +""") +public struct RestoreImageRecord: Codable, Sendable { + // ... +} +``` + +### 2.2 Create Migration Guide Document + +Add `MIGRATION-BUSHELCLOUDDATA.md` to BushelCloud: + +```markdown +# BushelCloudData Migration Guide + +## Status + +The `BushelCloudData` target is **deprecated** and will be removed in a future release. +All types are moving to BushelKit: + +- Models → BushelKit.BushelFoundation +- Fetchers → BushelKit.BushelHub +- Utilities → BushelKit.BushelUtilities + +## Timeline + +- **Current (v0.1.x):** BushelCloudData available but deprecated +- **Next (v0.2.0):** BushelKit modules available, BushelCloudData still present +- **Future (v1.0.0):** BushelCloudData removed entirely + +## Migration Path + +Update your Package.swift: +\`\`\`swift +dependencies: [ + .package(url: "https://github.com/brightdigit/BushelKit.git", from: "3.0.0") +] + +.target( + name: "YourTarget", + dependencies: [ + .product(name: "BushelFoundation", package: "BushelKit"), + .product(name: "BushelHub", package: "BushelKit"), + ] +) +\`\`\` + +Update your imports: +\`\`\`swift +// Before +import BushelCloudData + +// After +import BushelFoundation // For models +import BushelHub // For fetchers +\`\`\` +``` + +### 2.3 Test with Deprecation Warnings + +```bash +swift build 2>&1 | grep -i deprecated +``` + +Verify all BushelCloudData usage shows deprecation warnings. + +### 2.4 Commit Phase 2 + +```bash +git commit -am "docs: add comprehensive deprecation warnings and migration guide" +``` + +## Phase 3: Migrate to BushelKit Incrementally (Weeks 3-4) + +**Goal:** Move code from BushelCloudData to BushelKit, one module at a time + +### 3.1 Update BushelKit Package.swift (Add Dependencies) + +```swift +// /Users/leo/Documents/Projects/BushelKit/Package.swift + +dependencies: [ + // Add for data fetching + .package(url: "https://github.com/brightdigit/IPSWDownloads.git", from: "1.0.0"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), + // ... existing dependencies +] + +// Update existing targets +targets: [ + .target( + name: "BushelFoundation", + dependencies: [ + // No new dependencies - models are plain Swift + ] + ), + .target( + name: "BushelHub", + dependencies: [ + .target(name: "BushelFoundation"), + .target(name: "BushelLogging"), + .product(name: "IPSWDownloads", package: "IPSWDownloads"), + .product(name: "SwiftSoup", package: "SwiftSoup"), + // ... existing dependencies + ] + ), + .target( + name: "BushelUtilities", + dependencies: [ + // Already has Foundation + ] + ), +] +``` + +### 3.2 Copy Models to BushelKit/BushelFoundation + +Copy models from BushelCloudData to BushelFoundation (remove deprecation attributes): + +```bash +cd /Users/leo/Documents/Projects/BushelKit +cp ../BushelCloud/Sources/BushelCloudData/Models/*.swift Sources/BushelFoundation/ +``` + +Remove `@available(*, deprecated, ...)` attributes from the BushelKit versions. + +### 3.3 Copy Fetchers to BushelKit/BushelHub + +```bash +cd /Users/leo/Documents/Projects/BushelKit +mkdir -p Sources/BushelHub/DataSources/AppleDB +mkdir -p Sources/BushelHub/DataSources/TheAppleWiki + +cp ../BushelCloud/Sources/BushelCloudData/DataSources/*.swift Sources/BushelHub/DataSources/ +cp -r ../BushelCloud/Sources/BushelCloudData/DataSources/AppleDB/*.swift Sources/BushelHub/DataSources/AppleDB/ +cp -r ../BushelCloud/Sources/BushelCloudData/DataSources/TheAppleWiki/*.swift Sources/BushelHub/DataSources/TheAppleWiki/ +``` + +Update imports in all fetcher files: +```swift +// Before +import BushelCloudData + +// After +import BushelFoundation // For models +``` + +Remove deprecation attributes. + +### 3.4 Copy Utilities to BushelKit + +```bash +cp ../BushelCloud/Sources/BushelCloudData/Utilities/*.swift Sources/BushelUtilities/ +cp ../BushelCloud/Sources/BushelCloudData/Configuration/*.swift Sources/BushelFoundation/ +``` + +Remove deprecation attributes. + +### 3.5 Test BushelKit Build + +```bash +cd /Users/leo/Documents/Projects/BushelKit +swift build +swift test +``` + +Verify all platforms build successfully. + +### 3.6 Tag BushelKit Release + +```bash +cd /Users/leo/Documents/Projects/BushelKit +git add . +git commit -m "feat: add models, fetchers, and utilities from BushelCloud + +- Add RestoreImageRecord, XcodeVersionRecord, SwiftVersionRecord to BushelFoundation +- Add data fetchers and DataSourcePipeline to BushelHub +- Add FormattingHelpers, ConsoleOutput to BushelUtilities +- Add FetchConfiguration to BushelFoundation + +These types were previously in BushelCloud's BushelCloudData target (now deprecated)" + +git tag v3.0.0-alpha.2 +git push origin main --tags +``` + +## Phase 4: Update BushelCloud to Use BushelKit (Weeks 5-6) + +**Goal:** Gradually switch BushelCloud from BushelCloudData to BushelKit modules + +### 4.1 Update BushelCloud Package.swift + +```swift +// /Users/leo/Documents/Projects/BushelCloud/Package.swift + +dependencies: [ + .package(url: "https://github.com/brightdigit/MistKit.git", from: "1.0.0-alpha.3"), + .package(url: "https://github.com/brightdigit/BushelKit.git", from: "3.0.0-alpha.2"), // UPDATED + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + // Remove IPSWDownloads and SwiftSoup - now transitive via BushelKit +] + +targets: [ + .target( + name: "BushelCloudKit", + dependencies: [ + .product(name: "MistKit", package: "MistKit"), + .product(name: "BushelLogging", package: "BushelKit"), + .product(name: "BushelFoundation", package: "BushelKit"), // NEW + .product(name: "BushelHub", package: "BushelKit"), // NEW + .target(name: "BushelCloudData"), // KEEP temporarily for transition + ] + ), + + // BushelCloudData target (keep for now, will remove in Phase 5) + .target( + name: "BushelCloudData", + dependencies: [ + // Keep as-is for now + ] + ), + + .executableTarget( + name: "BushelCloudCLI", + dependencies: [ + .target(name: "BushelCloudKit"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + // Remove BushelData dependency (now via BushelCloudKit) + ] + ), +] +``` + +### 4.2 Update Imports in BushelCloudKit Extensions + +```swift +// Sources/BushelCloudKit/Extensions/RestoreImageRecord+CloudKit.swift +import MistKit +import BushelFoundation // CHANGED from BushelCloudData +import Foundation + +extension RestoreImageRecord: CloudKitRecord { + // Implementation unchanged +} +``` + +Update all 4 extension files to import from BushelFoundation instead of BushelCloudData. + +### 4.3 Update Imports in CloudKit Service Files + +```swift +// Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +import MistKit +import BushelFoundation // CHANGED from BushelCloudData +import BushelLogging +import Foundation + +// Sources/BushelCloudKit/CloudKit/SyncEngine.swift +import MistKit +import BushelFoundation // CHANGED from BushelCloudData +import BushelHub // CHANGED from BushelCloudData +import BushelLogging +import Foundation +``` + +### 4.4 Test BushelCloud Build + +```bash +cd /Users/leo/Documents/Projects/BushelCloud +swift build +``` + +At this point: +- BushelCloudKit uses BushelKit modules +- BushelCloudData still exists but is unused +- Deprecation warnings may still appear (that's ok) + +Test all CLI commands: +```bash +.build/debug/bushel-cloud sync --dry-run --verbose +.build/debug/bushel-cloud export --output test.json --verbose +.build/debug/bushel-cloud list +.build/debug/bushel-cloud status +``` + +### 4.5 Commit Phase 4 + +```bash +git commit -am "refactor: migrate from BushelCloudData to BushelKit modules + +- Update imports to use BushelFoundation and BushelHub +- Keep BushelCloudData target for backward compatibility (to be removed)" +``` + +## Phase 5: Remove BushelCloudData Target (Week 7) + +**Goal:** Clean up deprecated code once BushelKit migration is complete + +### 5.1 Remove BushelCloudData Target from Package.swift + +```swift +// Remove entire BushelCloudData target +targets: [ + .target( + name: "BushelCloudKit", + dependencies: [ + .product(name: "MistKit", package: "MistKit"), + .product(name: "BushelLogging", package: "BushelKit"), + .product(name: "BushelFoundation", package: "BushelKit"), + .product(name: "BushelHub", package: "BushelKit"), + // BushelCloudData dependency REMOVED + ] + ), + // BushelCloudData target REMOVED entirely +] +``` + +### 5.2 Delete BushelCloudData Directory + +```bash +rm -rf Sources/BushelCloudData +``` + +### 5.3 Delete Old Model/Fetcher Files from BushelCloudKit + +```bash +rm -rf Sources/BushelCloudKit/Models +rm -rf Sources/BushelCloudKit/DataSources +rm -rf Sources/BushelCloudKit/Utilities +rm -rf Sources/BushelCloudKit/Configuration +``` + +### 5.4 Final BushelCloudKit Structure + +Verify final structure: + +``` +Sources/BushelCloudKit/ +├── CloudKit/ +│ ├── BushelCloudKitService.swift +│ ├── SyncEngine.swift +│ ├── RecordManaging+Query.swift +│ └── BushelCloudKitError.swift +├── Extensions/ +│ ├── RestoreImageRecord+CloudKit.swift +│ ├── XcodeVersionRecord+CloudKit.swift +│ ├── SwiftVersionRecord+CloudKit.swift +│ └── DataSourceMetadata+CloudKit.swift +├── CloudKitRecord.swift +└── BushelCloud.docc/ (optional) +``` + +### 5.5 Full Integration Test + +Test all CLI commands to ensure everything still works: + +```bash +cd /Users/leo/Documents/Projects/BushelCloud +swift build + +# Test all commands +.build/debug/bushel-cloud sync --dry-run --verbose +.build/debug/bushel-cloud sync --verbose +.build/debug/bushel-cloud export --output final-test.json --verbose +.build/debug/bushel-cloud list +.build/debug/bushel-cloud status +``` + +### 5.6 Run Full Test Suite + +```bash +swift test +./Scripts/lint.sh +``` + +### 5.7 Commit Phase 5 + +```bash +git add . +git commit -m "refactor: remove deprecated BushelCloudData target + +- Remove BushelCloudData target completely +- All code now in BushelKit (BushelFoundation, BushelHub, BushelUtilities) +- BushelCloudKit focused on CloudKit integration only +- Clean final architecture with clear separation of concerns" + +git tag v0.2.0 +git push origin main --tags +``` + +## Documentation Updates + +### Update BushelCloud Documentation + +**README.md:** +```markdown +## Architecture + +BushelCloud demonstrates CloudKit integration patterns using BushelKit: + +- **Models** (BushelFoundation): Plain Swift domain models +- **Data Fetchers** (BushelHub): Fetch data from external APIs +- **CloudKit Integration** (BushelCloudKit): MistKit-based CloudKit sync +- **CLI** (BushelCloudCLI): Command-line interface + +Dependencies: +- BushelKit 3.0+ (models, fetchers, utilities) +- MistKit 1.0+ (CloudKit Web Services) +``` + +**CLAUDE.md:** +```markdown +## Dependencies + +- **BushelKit** (3.0.0-alpha.2+) - Provides: + - BushelFoundation: Core models (RestoreImageRecord, XcodeVersionRecord, SwiftVersionRecord) + - BushelHub: Data fetchers and pipeline + - BushelUtilities: Formatting and console utilities + - BushelLogging: Structured logging + +- **MistKit** (1.0.0-alpha.3+) - CloudKit Web Services client + +Import example: +\`\`\`swift +import BushelFoundation // For models +import BushelHub // For fetchers +import MistKit // For CloudKit (BushelCloudKit only) +\`\`\` +``` + +**Delete MIGRATION-BUSHELCLOUDDATA.md** (no longer needed after Phase 5) + +### Update BushelKit Documentation + +Add to **BushelFoundation.docc**: +```markdown +# Models from BushelCloud + +BushelFoundation includes domain models originally from the BushelCloud demo: + +- `RestoreImageRecord` - macOS restore images (IPSW) +- `XcodeVersionRecord` - Xcode releases +- `SwiftVersionRecord` - Swift compiler versions +- `DataSourceMetadata` - Fetch metadata tracking + +These are plain Swift structs with no CloudKit dependencies, suitable for any use case. +``` + +Add to **BushelHub.docc**: +```markdown +# Data Fetchers from BushelCloud + +BushelHub includes data fetchers for Apple platform software: + +- `IPSWFetcher` - Fetch from ipsw.me +- `AppleDBFetcher` - Fetch from AppleDB +- `XcodeReleasesFetcher` - Fetch from xcodereleases.com +- `DataSourcePipeline` - Orchestrate multiple fetchers + +See BushelCloud for complete working examples. +``` + +## Critical Files + +### BushelCloud Files + +1. **`/Users/leo/Documents/Projects/BushelCloud/Package.swift`** + - Phase 1: Add BushelCloudData target + - Phase 4: Add BushelKit module dependencies + - Phase 5: Remove BushelCloudData target + +2. **`/Users/leo/Documents/Projects/BushelCloud/Sources/BushelCloudKit/CloudKitRecord.swift`** (NEW) + - Phase 1: Create protocol for CloudKit serialization + +3. **`/Users/leo/Documents/Projects/BushelCloud/Sources/BushelCloudKit/Extensions/RestoreImageRecord+CloudKit.swift`** (NEW) + - Phase 1: Create CloudKit extension + - Phase 4: Update import to BushelFoundation + +4. **`/Users/leo/Documents/Projects/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift`** + - Phase 1: Update imports to BushelCloudData + - Phase 4: Update imports to BushelHub + +5. **`/Users/leo/Documents/Projects/BushelCloud/Sources/BushelCloudData/Models/RestoreImageRecord.swift`** (NEW, then DELETED) + - Phase 1: Create as plain struct with deprecation + - Phase 5: Delete (now in BushelKit) + +### BushelKit Files + +1. **`/Users/leo/Documents/Projects/BushelKit/Package.swift`** + - Phase 3: Add IPSWDownloads and SwiftSoup dependencies + - Phase 3: Update BushelHub target dependencies + +2. **`/Users/leo/Documents/Projects/BushelKit/Sources/BushelFoundation/RestoreImageRecord.swift`** (NEW) + - Phase 3: Copy from BushelCloudData, remove deprecation + +3. **`/Users/leo/Documents/Projects/BushelKit/Sources/BushelHub/DataSources/DataSourcePipeline.swift`** (NEW) + - Phase 3: Copy from BushelCloudData, update imports + +## Success Criteria + +- [ ] **Phase 1:** BushelCloudData target exists, all tests pass, deprecation warnings appear +- [ ] **Phase 2:** Migration guide created, comprehensive deprecation messages added +- [ ] **Phase 3:** BushelKit 3.0.0-alpha.2 tagged with models/fetchers/utilities +- [ ] **Phase 4:** BushelCloud uses BushelKit modules, all CLI commands work +- [ ] **Phase 5:** BushelCloudData removed, final structure clean, all tests pass +- [ ] **Documentation:** All docs updated to reflect new architecture +- [ ] **CI/CD:** All GitHub Actions workflows pass +- [ ] **No Breaking Changes:** CLI interface unchanged throughout migration + +## Timeline Summary + +| Phase | Duration | Milestone | +|-------|----------|-----------| +| Phase 1 | Week 1 | BushelCloudData target created, code isolated | +| Phase 2 | Week 2 | Deprecation warnings added | +| Phase 3 | Weeks 3-4 | Code migrated to BushelKit, v3.0.0-alpha.2 tagged | +| Phase 4 | Weeks 5-6 | BushelCloud updated to use BushelKit | +| Phase 5 | Week 7 | BushelCloudData removed, final cleanup | + +**Total:** 7 weeks for complete gradual migration + +## Benefits of This Approach + +1. **Safety:** Code stays working throughout migration +2. **Visibility:** Deprecation warnings guide developers +3. **Testability:** Each phase can be tested independently +4. **Reversibility:** Can pause or rollback at any phase +5. **Minimal Disruption:** CLI interface never changes +6. **Clear Communication:** Deprecation messages explain what to do + +--- + +**End of Migration Plan** diff --git a/Examples/BushelCloud/.claude/s2s-auth-details.md b/Examples/BushelCloud/.claude/s2s-auth-details.md new file mode 100644 index 00000000..83fcd889 --- /dev/null +++ b/Examples/BushelCloud/.claude/s2s-auth-details.md @@ -0,0 +1,380 @@ +# Server-to-Server Authentication (Implementation Details) + +> **Note**: This is a detailed reference guide for CloudKit Server-to-Server authentication implementation. For daily development, see the main [CLAUDE.md](../CLAUDE.md) file. + +This document explains how BushelCloud implements CloudKit Server-to-Server (S2S) authentication using MistKit, including internal workings and best practices. + +## What is Server-to-Server Authentication? + +Server-to-Server authentication allows backend services, scripts, or CLI tools to access CloudKit **without requiring a signed-in iCloud user**. This is essential for: + +- Automated data syncing from external APIs +- Scheduled batch operations +- Server-side data processing +- Command-line tools that manage CloudKit data + +## How It Works + +1. **Generate a Server-to-Server key pair** (ECDSA P-256) +2. **Register public key** in CloudKit Dashboard, receive Key ID +3. **Sign requests** using private key and Key ID (handled by MistKit) +4. **CloudKit authenticates** requests as the developer/"_creator" role +5. **Schema permissions** grant access based on "_creator" and "_icloud" roles + +## BushelCloudKitService Implementation Pattern + +BushelCloud wraps MistKit's `CloudKitService` for convenience: + +```swift +struct BushelCloudKitService { + let service: CloudKitService + + init( + containerIdentifier: String, + keyID: String, + privateKeyPath: String + ) throws { + // 1. Validate file exists + guard FileManager.default.fileExists(atPath: privateKeyPath) else { + throw BushelCloudKitError.privateKeyFileNotFound(path: privateKeyPath) + } + + // 2. Read PEM file + let pemString = try String(contentsOfFile: privateKeyPath, encoding: .utf8) + + // 3. Create S2S authentication manager + let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + pemString: pemString + ) + + // 4. Initialize CloudKit service + self.service = try CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: .development, // or .production + database: .public + ) + } +} +``` + +## How ServerToServerAuthManager Works Internally + +**Initialization:** +```swift +let tokenManager = try ServerToServerAuthManager( + keyID: "your-key-id", + pemString: pemFileContents // Reads from .pem file +) +``` + +**What happens internally (MistKit):** +1. Parses PEM string into ECDSA P-256 private key using Swift Crypto +2. Stores key ID and private key data +3. Creates `TokenCredentials` with `.serverToServer` authentication method + +**Request signing (automatic):** +- For each CloudKit API request +- MistKit creates a signature using the private key +- Sends Key ID + signature in HTTP headers +- CloudKit server verifies with registered public key + +## Security Best Practices + +### Private Key Storage + +**Secure storage:** +```bash +# Create secure directory +mkdir -p ~/.cloudkit +chmod 700 ~/.cloudkit + +# Store private key securely +mv ~/Downloads/AuthKey_*.pem ~/.cloudkit/bushel-private-key.pem +chmod 600 ~/.cloudkit/bushel-private-key.pem +``` + +**Environment setup:** +```bash +# Add to ~/.zshrc or ~/.bashrc +export CLOUDKIT_KEY_ID="your_key_id" +export CLOUDKIT_KEY_FILE="$HOME/.cloudkit/bushel-private-key.pem" +export CLOUDKIT_CONTAINER_ID="iCloud.com.brightdigit.Bushel" +``` + +**Git protection:** +```gitignore +# .gitignore +*.pem +*.p8 +.env +.cloudkit/ +``` + +### Key Management Rules + +**Never:** +- ❌ Commit .pem files to version control +- ❌ Share private keys in Slack/email +- ❌ Store in public locations +- ❌ Use same key across development/production +- ❌ Hardcode keys in source code + +**Always:** +- ✅ Use environment variables +- ✅ Set restrictive file permissions (600) +- ✅ Store in user-specific locations (~/.cloudkit/) +- ✅ Generate separate keys per environment +- ✅ Rotate keys periodically (every 6-12 months) + +## Common Authentication Errors + +### "Private key file not found" + +```text +BushelCloudKitError.privateKeyFileNotFound(path: "./key.pem") +``` + +**Cause:** File doesn't exist at specified path or wrong working directory + +**Solution:** +- Use absolute path: `$HOME/.cloudkit/bushel-private-key.pem` +- Or verify current working directory matches expected location +- Check file permissions (readable by current user) + +### "PEM string is invalid" + +```text +TokenManagerError.invalidCredentials(.invalidPEMFormat) +``` + +**Cause:** .pem file is corrupted or not in correct format + +**Solution:** +- Verify file has proper BEGIN/END markers: + ``` + -----BEGIN EC PRIVATE KEY----- + ... + -----END EC PRIVATE KEY----- + ``` +- Re-download from CloudKit Dashboard if corrupted +- Ensure UTF-8 encoding (no binary corruption) + +### "Key ID is empty" + +```text +TokenManagerError.invalidCredentials(.keyIdEmpty) +``` + +**Cause:** Key ID not provided or environment variable not set + +**Solution:** +```bash +# Check environment variable +echo $CLOUDKIT_KEY_ID + +# Set if missing +export CLOUDKIT_KEY_ID="your-key-id-here" +``` + +### "ACCESS_DENIED - CREATE operation not permitted" + +```json +{ + "recordName": "RestoreImage-24A335", + "reason": "CREATE operation not permitted", + "serverErrorCode": "ACCESS_DENIED" +} +``` + +**Cause:** Schema permissions don't grant CREATE to `_creator` and `_icloud` + +**Solution:** Update schema with both permission grants: +```text +GRANT READ, CREATE, WRITE TO "_creator", +GRANT READ, CREATE, WRITE TO "_icloud", +``` + +**Critical:** Both roles are required. Only granting to one causes ACCESS_DENIED errors. + +## Operation Types and Permissions + +CloudKit operations have different permission requirements: + +**READ operations:** +- `queryRecords()` - Requires READ permission +- `fetchRecords()` - Requires READ permission + +**CREATE operations:** +- `RecordOperation.create()` - Requires CREATE permission +- First-time record creation + +**WRITE operations:** +- `RecordOperation.update()` - Requires WRITE permission +- Modifying existing records + +**REPLACE operations:** +- `RecordOperation.forceReplace()` - Requires both CREATE and WRITE +- Creates if doesn't exist, updates if exists +- **Recommended for idempotent syncs** + +## Batch Operations and Limits + +CloudKit enforces a **200 operations per request** limit: + +```swift +func syncRecords(_ records: [RestoreImageRecord]) async throws { + let operations = records.map { record in + RecordOperation.create( + recordType: RestoreImageRecord.cloudKitRecordType, + recordName: record.recordName, + fields: record.toCloudKitFields() + ) + } + + // Chunk into batches of 200 + let batchSize = 200 + let batches = operations.chunked(into: batchSize) + + for (index, batch) in batches.enumerated() { + print("Batch \(index + 1)/\(batches.count): \(batch.count) records...") + let results = try await service.modifyRecords(batch) + + // Check for partial failures + let failures = results.filter { $0.recordType == "Unknown" } + let successes = results.filter { $0.recordType != "Unknown" } + + print("✓ \(successes.count) succeeded, ❌ \(failures.count) failed") + } +} +``` + +## Error Handling in Batch Operations + +CloudKit returns **partial success** - some operations may succeed while others fail: + +```swift +let results = try await service.modifyRecords(batch) + +for result in results { + if result.recordType == "Unknown" { + // This is an error response + print("❌ Error for \(result.recordName ?? "unknown")") + print(" Code: \(result.serverErrorCode ?? "N/A")") + print(" Reason: \(result.reason ?? "N/A")") + } else { + // Successfully created/updated + print("✓ Success: \(result.recordName ?? "unknown")") + } +} +``` + +**Common error codes:** +- `ACCESS_DENIED` - Permission denied (check schema permissions) +- `AUTHENTICATION_FAILED` - Invalid key ID or signature +- `CONFLICT` - Record version mismatch (use `.forceReplace` to avoid) +- `QUOTA_EXCEEDED` - Too many operations or storage limit reached +- `VALIDATING_REFERENCE_ERROR` - Referenced record doesn't exist + +## Reference Resolution + +When creating records with references, upload order matters: + +```swift +// 1. Upload referenced records first (no dependencies) +try await uploadSwiftVersions() // No dependencies +try await uploadRestoreImages() // No dependencies + +// 2. Upload records with references last +try await uploadXcodeVersions() // References SwiftVersion and RestoreImage +``` + +**Creating a reference:** +```swift +fields["minimumMacOS"] = .reference( + FieldValue.Reference(recordName: "RestoreImage-23C71") +) +fields["swiftVersion"] = .reference( + FieldValue.Reference(recordName: "SwiftVersion-6.0") +) +``` + +**Reference validation:** CloudKit validates that referenced records exist. If not, returns `VALIDATING_REFERENCE_ERROR`. + +## Testing S2S Authentication + +**1. Test authentication:** +```swift +let records = try await service.queryRecords(recordType: "RestoreImage", limit: 1) +print("✓ Authentication successful, found \(records.count) records") +``` + +**2. Test record creation:** +```swift +let testRecord = RestoreImageRecord( + version: "18.0", + buildNumber: "22A123", + releaseDate: Date(), + downloadURL: "https://example.com/test.ipsw", + fileSize: 1000000, + sha256Hash: "abc123", + sha1Hash: "def456", + isSigned: true, + isPrerelease: false, + source: "test" +) + +let operation = RecordOperation.create( + recordType: RestoreImageRecord.cloudKitRecordType, + recordName: testRecord.recordName, + fields: testRecord.toCloudKitFields() +) + +let results = try await service.modifyRecords([operation]) + +if results.first?.recordType == "Unknown" { + print("❌ Failed: \(results.first?.reason ?? "unknown")") +} else { + print("✓ Success! Record created: \(results.first?.recordName ?? "")") +} +``` + +**3. Verify in CloudKit Dashboard:** +1. Go to CloudKit Dashboard +2. Select your Container +3. Navigate to Data → Public Database +4. Select record type +5. Verify test record appears + +## Environment: Development vs Production + +**Development environment:** +- Use `environment: .development` in CloudKitService init +- Separate schema from production +- Test freely without affecting production data +- Changes deploy instantly + +**Production environment:** +- Use `environment: .production` +- Requires schema deployment from development +- Real user data - be cautious +- Changes may require app updates + +**Best practice:** +```swift +let environment: CloudKitEnvironment = { + #if DEBUG + return .development + #else + return .production + #endif +}() + +let service = try CloudKitService( + containerIdentifier: containerID, + tokenManager: tokenManager, + environment: environment, + database: .public +) +``` diff --git a/Examples/BushelCloud/.claude/schema-management.md b/Examples/BushelCloud/.claude/schema-management.md new file mode 100644 index 00000000..34af20fb --- /dev/null +++ b/Examples/BushelCloud/.claude/schema-management.md @@ -0,0 +1,320 @@ +# CloudKit Schema Management (Advanced) + +> **Note**: This is a detailed reference guide for advanced CloudKit schema management. For daily development, see the main [CLAUDE.md](../CLAUDE.md) file. + +This document covers advanced schema management, `cktool` usage, and troubleshooting for developers working with CloudKit schemas. + +## Schema File Format + +CloudKit schemas use a declarative language defined in `.ckdb` files: + +```text +DEFINE SCHEMA + +RECORD TYPE RestoreImage ( + "version" STRING QUERYABLE SORTABLE SEARCHABLE, + "buildNumber" STRING QUERYABLE SORTABLE, + "releaseDate" TIMESTAMP QUERYABLE SORTABLE, + "downloadURL" STRING, + "fileSize" INT64, + "sha256Hash" STRING, + "sha1Hash" STRING, + "isSigned" INT64 QUERYABLE, + "isPrerelease" INT64 QUERYABLE, + "source" STRING, + "notes" STRING, + "sourceUpdatedAt" TIMESTAMP, + + GRANT READ, CREATE, WRITE TO "_creator", + GRANT READ, CREATE, WRITE TO "_icloud", + GRANT READ TO "_world" +); +``` + +## Critical Schema Rules + +**1. Always start with `DEFINE SCHEMA`:** +```text +DEFINE SCHEMA ← Required header + +RECORD TYPE YourType ( + ... +) +``` + +**2. Never include system fields:** +CloudKit automatically adds system fields like `___recordID`, `___createTime`, `___modTime`. Including them causes validation errors. + +**Bad:** +```text +RECORD TYPE RestoreImage ( + "___recordID" QUERYABLE, ← ❌ ERROR + "version" STRING +) +``` + +**Good:** +```text +RECORD TYPE RestoreImage ( + "version" STRING ← ✅ Only user-defined fields +) +``` + +**3. Use INT64 for booleans:** +CloudKit doesn't have a native boolean type. + +```text +"isSigned" INT64 QUERYABLE, # 0 = false, 1 = true +"isPrerelease" INT64 QUERYABLE, +``` + +**4. Field modifiers:** +- `QUERYABLE` - Can be used in query predicates +- `SORTABLE` - Can be used for sorting results +- `SEARCHABLE` - Supports full-text search (STRING only) + +## Permission Requirements for Server-to-Server Auth + +**Critical:** S2S authentication requires BOTH `_creator` AND `_icloud` permissions: + +```text +GRANT READ, CREATE, WRITE TO "_creator", +GRANT READ, CREATE, WRITE TO "_icloud", +GRANT READ TO "_world" +``` + +**Why both are required:** +- `_creator` - S2S keys authenticate as the developer/application +- `_icloud` - Required for public database operations + +**Common mistake:** Only granting to one role results in `ACCESS_DENIED` errors. + +## cktool Commands Reference + +### Save Management Token + +Management tokens allow schema operations: + +```bash +xcrun cktool save-token +# Paste token from CloudKit Dashboard when prompted +``` + +**Getting a management token:** +1. CloudKit Dashboard → Select container +2. Settings → CloudKit Web Services +3. Generate Management Token +4. Copy and save with `cktool save-token` + +### Validate Schema + +Check schema syntax without importing: + +```bash +xcrun cktool validate-schema \ + --team-id YOUR_TEAM_ID \ + --container-id iCloud.com.yourcompany.Bushel \ + --environment development \ + --file schema.ckdb +``` + +### Import Schema + +Upload schema to CloudKit: + +```bash +xcrun cktool import-schema \ + --team-id YOUR_TEAM_ID \ + --container-id iCloud.com.yourcompany.Bushel \ + --environment development \ + --file schema.ckdb +``` + +**Note:** This modifies your CloudKit container. Always test in development first! + +### Export Schema + +Download current schema from CloudKit: + +```bash +xcrun cktool export-schema \ + --team-id YOUR_TEAM_ID \ + --container-id iCloud.com.yourcompany.Bushel \ + --environment development \ + > schema-backup.ckdb +``` + +**Use cases:** +- Backup before making changes +- Verify what's currently deployed +- Compare development vs production schemas + +### Query Records + +Test queries with cktool: + +```bash +xcrun cktool query \ + --team-id YOUR_TEAM_ID \ + --container-id iCloud.com.yourcompany.Bushel \ + --environment development \ + --record-type RestoreImage \ + --limit 10 +``` + +## Common cktool Errors + +**"Authentication failed":** +- **Solution:** Generate new management token and save with `cktool save-token` + +**"Container not found":** +- **Solution:** Verify container ID matches Dashboard exactly +- Check Team ID is correct + +**"Schema validation failed: Was expecting DEFINE":** +- **Solution:** Add `DEFINE SCHEMA` header at top of `.ckdb` file + +**"Schema validation failed: Encountered '___recordID'":** +- **Solution:** Remove all system fields from schema (they're auto-added) + +**"Permission denied":** +- **Solution:** Ensure your Apple ID has Admin role in the container + +## Schema Versioning Best Practices + +**1. Version control your schema:** +```bash +git add schema.ckdb +git commit -m "Add DataSourceMetadata record type" +``` + +**2. Test in development first:** +```bash +# Import to development environment +xcrun cktool import-schema --environment development --file schema.ckdb + +# Test with your app +bushel-cloud sync --verbose + +# If successful, deploy to production +xcrun cktool import-schema --environment production --file schema.ckdb +``` + +**3. Backward compatibility:** +- Avoid removing fields (breaks existing records) +- Mark fields optional instead of removing +- Add new fields as optional +- Update all clients before schema changes + +**4. Export before major changes:** +```bash +# Backup current production schema +xcrun cktool export-schema --environment production > schema-backup-$(date +%Y%m%d).ckdb +``` + +## CI/CD Schema Deployment + +Automate schema deployment in CI/CD pipelines: + +```bash +#!/bin/bash +# Schema deployment script + +# Load token from secure environment variable +echo "$CLOUDKIT_MANAGEMENT_TOKEN" | xcrun cktool save-token --file - + +# Validate schema first +xcrun cktool validate-schema \ + --team-id "$TEAM_ID" \ + --container-id "$CONTAINER_ID" \ + --environment development \ + --file schema.ckdb + +# Import if validation passes +xcrun cktool import-schema \ + --team-id "$TEAM_ID" \ + --container-id "$CONTAINER_ID" \ + --environment development \ + --file schema.ckdb +``` + +**Security:** Store management token in CI secrets, never commit to repository. + +## Token Types Clarification + +CloudKit uses different tokens for different purposes: + +| Token Type | Purpose | Used By | Where to Get | +|-----------|---------|---------|--------------| +| **Management Token** | Schema operations (import/export) | `cktool` | CloudKit Dashboard → CloudKit Web Services | +| **Server-to-Server Key** | Runtime API operations | Your application | CloudKit Dashboard → Server-to-Server Keys | +| **API Token** | Simpler runtime auth (deprecated) | Legacy apps | CloudKit Dashboard → API Tokens | + +**For BushelCloud:** +- Schema setup: **Management Token** (via `cktool save-token`) +- Sync/export commands: **Server-to-Server Key** (Key ID + .pem file) + +## Troubleshooting Schema Import + +**Schema imports successfully but records still fail to create:** + +1. **Check permissions in exported schema:** + ```bash + xcrun cktool export-schema --environment development | grep -A 2 "GRANT" + ``` + Should show both `_creator` and `_icloud` with CREATE, READ, WRITE + +2. **Verify field types match your code:** + Export schema and compare field types to your `toCloudKitFields()` implementation + +3. **Test with a simple record:** + ```swift + let testOp = RecordOperation.create( + recordType: "RestoreImage", + recordName: "test-123", + fields: ["version": .string("1.0")] + ) + try await service.modifyRecords([testOp]) + ``` + +**Permissions seem correct but still get ACCESS_DENIED:** + +CloudKit schema changes can take a few minutes to propagate. Wait 5-10 minutes and try again. + +## Database Scope Considerations + +Schema import applies to the **container level**, making record types available in both public and private databases. + +**BushelCloud configuration:** +- Writes to **public database** (see `BushelCloudKitService.swift`) +- `GRANT READ TO "_world"` enables public read access +- S2S auth uses public database scope + +**Private database** would require: +- User authentication +- Different permission model +- Per-user data isolation + +BushelCloud uses public database to demonstrate server-managed shared data accessible to all users. + +## Advanced: Programmatic Schema Validation + +You can validate CloudKit field values before upload: + +```swift +// Example validation helper +func validateRestoreImageFields(_ fields: [String: FieldValue]) throws { + guard case .string(let version) = fields["version"], !version.isEmpty else { + throw ValidationError.missingRequiredField("version") + } + + guard case .int64(let isSigned) = fields["isSigned"], + (isSigned == 0 || isSigned == 1) else { + throw ValidationError.invalidBooleanValue("isSigned") + } + + // ... more validations +} +``` + +This catches errors before CloudKit upload, providing better error messages. diff --git a/Examples/BushelCloud/.devcontainer/devcontainer.json b/Examples/BushelCloud/.devcontainer/devcontainer.json new file mode 100644 index 00000000..6fed9bab --- /dev/null +++ b/Examples/BushelCloud/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.2", + "image": "swift:6.2", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "sswg.swift-lang" + ] + } + }, + "remoteUser": "root" +} \ No newline at end of file diff --git a/Examples/BushelCloud/.devcontainer/swift-6.2-nightly/devcontainer.json b/Examples/BushelCloud/.devcontainer/swift-6.2-nightly/devcontainer.json new file mode 100644 index 00000000..b5bd73c4 --- /dev/null +++ b/Examples/BushelCloud/.devcontainer/swift-6.2-nightly/devcontainer.json @@ -0,0 +1,40 @@ +{ + "name": "Swift 6.2 Nightly", + "image": "swiftlang/swift:nightly-6.2-noble", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "sswg.swift-lang" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "root" +} \ No newline at end of file diff --git a/Examples/BushelCloud/.devcontainer/swift-6.2/devcontainer.json b/Examples/BushelCloud/.devcontainer/swift-6.2/devcontainer.json new file mode 100644 index 00000000..6fed9bab --- /dev/null +++ b/Examples/BushelCloud/.devcontainer/swift-6.2/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.2", + "image": "swift:6.2", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "sswg.swift-lang" + ] + } + }, + "remoteUser": "root" +} \ No newline at end of file diff --git a/Examples/BushelCloud/.devcontainer/swift-6.3-nightly/devcontainer.json b/Examples/BushelCloud/.devcontainer/swift-6.3-nightly/devcontainer.json new file mode 100644 index 00000000..5d13cef8 --- /dev/null +++ b/Examples/BushelCloud/.devcontainer/swift-6.3-nightly/devcontainer.json @@ -0,0 +1,40 @@ +{ + "name": "Swift 6.3 Nightly Development Container", + "image": "swiftlang/swift:nightly-6.3-noble", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "sswg.swift-lang" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "root" +} \ No newline at end of file diff --git a/Examples/BushelCloud/.env.example b/Examples/BushelCloud/.env.example new file mode 100644 index 00000000..b89a0644 --- /dev/null +++ b/Examples/BushelCloud/.env.example @@ -0,0 +1,109 @@ +# BushelCloud Environment Variables +# Copy this file to .env and fill in your actual values +# IMPORTANT: Never commit .env to version control! + +# ============================================ +# CloudKit Configuration +# ============================================ + +# CloudKit Container ID +# Find this in: https://icloud.developer.apple.com/dashboard/ +# Format: iCloud.com.company.AppName +CLOUDKIT_CONTAINER_ID=iCloud.com.brightdigit.Bushel + +# CloudKit Environment +# Options: development, production +# Use 'development' for testing, 'production' for live data +CLOUDKIT_ENVIRONMENT=development + +# CloudKit Database +# Options: public, private, shared +# BushelCloud uses the public database +CLOUDKIT_DATABASE=public + +# ============================================ +# Server-to-Server Authentication +# ============================================ + +# Server-to-Server Key ID +# Get this from: CloudKit Dashboard → API Access → Server-to-Server Keys +# Format: 32-character hexadecimal string +CLOUDKIT_KEY_ID=your-key-id-here + +# Path to Private Key (.pem file) +# Download from CloudKit Dashboard when creating the S2S key +# Recommended location: ~/.cloudkit/bushel-private-key.pem +# NEVER commit this file to version control! +CLOUDKIT_PRIVATE_KEY_PATH=$HOME/.cloudkit/bushel-private-key.pem + +# ============================================ +# Schema Management (cktool) +# ============================================ + +# Apple Developer Team ID +# Find this in: https://developer.apple.com/account → Membership +# Format: 10-character alphanumeric string +CLOUDKIT_TEAM_ID=your-team-id + +# ============================================ +# Optional: Data Source Configuration +# ============================================ + +# VirtualBuddy TSS API Key +# Required for: VirtualBuddy signing status verification +# Get this from: https://tss.virtualbuddy.app/ +# Used to check real-time TSS signing status for restore images +# Leave empty to skip VirtualBuddy enrichment +# VIRTUALBUDDY_API_KEY=your-virtualbuddy-api-key + +# Fetch interval overrides (in seconds) +# Uncomment to override default throttling intervals +# IPSW_FETCH_INTERVAL=3600 +# APPLEDB_FETCH_INTERVAL=7200 +# MESU_FETCH_INTERVAL=1800 +# XCODE_RELEASES_FETCH_INTERVAL=3600 + +# ============================================ +# Optional: Logging Configuration +# ============================================ + +# Enable verbose logging (for debugging) +# Options: true, false +# BUSHEL_VERBOSE=false + +# Log level +# Options: debug, info, warning, error +# LOG_LEVEL=info + +# ============================================ +# Development Settings +# ============================================ + +# Xcode scheme configuration (for Xcode IDE users) +# These values should match what you set in Xcode's scheme editor: +# Product → Scheme → Edit Scheme → Run → Arguments → Environment Variables + +# Example usage in Xcode: +# 1. Open BushelCloud.xcodeproj +# 2. Product → Scheme → Edit Scheme (⌘<) +# 3. Run → Arguments tab → Environment Variables section +# 4. Add the variables above with your actual values + +# ============================================ +# Security Notes +# ============================================ + +# 1. NEVER commit .env to version control +# (.env is already in .gitignore) +# +# 2. NEVER commit .pem, .p8, or .key files +# (These are already in .gitignore) +# +# 3. Store private keys securely: +# - Use ~/.cloudkit/ directory (not in project) +# - Set restrictive permissions: chmod 600 ~/.cloudkit/*.pem +# - Consider using macOS Keychain for additional security +# +# 4. Rotate keys regularly in production environments +# +# 5. Use separate keys for development and production diff --git a/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md b/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md new file mode 100644 index 00000000..b21183a1 --- /dev/null +++ b/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md @@ -0,0 +1,290 @@ +# CloudKit Sync Workflow Setup + +This document explains how to configure the scheduled CloudKit sync workflow. + +## Prerequisites + +1. Access to the CloudKit Dashboard: https://icloud.developer.apple.com/dashboard/ +2. GitHub repository admin permissions (to add secrets) +3. Server-to-Server authentication key created in CloudKit + +## Setup Steps + +### 1. Create Server-to-Server Key (If Not Already Created) + +1. Visit https://icloud.developer.apple.com/dashboard/ +2. Select your container: `iCloud.com.brightdigit.Bushel` +3. Navigate to: **API Access → Server-to-Server Keys** +4. Click **+** to create a new key +5. Download the `.pem` file immediately (you can only download it once) +6. Save the file securely (e.g., `~/Downloads/AuthKey_XXXXXXXXXX.pem`) +7. Note the **Key ID** (32-character hex string) + +### 2. Add GitHub Secrets + +**Note**: A single S2S key works for both development and production environments. The environment is selected via the `CLOUDKIT_ENVIRONMENT` variable, not the key itself. + +1. Go to your repository on GitHub +2. Navigate to: **Settings → Secrets and variables → Actions** +3. Click **New repository secret** + +#### Add CLOUDKIT_KEY_ID + +- **Name:** `CLOUDKIT_KEY_ID` +- **Value:** Your 32-character key ID from step 1.7 +- Click **Add secret** + +#### Add CLOUDKIT_PRIVATE_KEY + +- **Name:** `CLOUDKIT_PRIVATE_KEY` +- **Value:** Full contents of your `.pem` file + + To get the content: + ```bash + cat ~/Downloads/AuthKey_XXXXXXXXXX.pem | pbcopy + ``` + + Or open in a text editor and copy all lines including: + ``` + -----BEGIN PRIVATE KEY----- + [base64 encoded key data] + -----END PRIVATE KEY----- + ``` + +- Click **Add secret** + +#### Optional: Separate Keys for Production (Advanced) + +For enhanced security in production environments, you can optionally use separate keys: + +**Benefits:** +- Compromised dev key doesn't affect production +- Different access controls per environment +- Independent key rotation schedules + +**Setup:** +- Create a second S2S key in CloudKit Dashboard for production +- Add `CLOUDKIT_PROD_KEY_ID` and `CLOUDKIT_PROD_PRIVATE_KEY` secrets +- Update workflow to use prod secrets when `CLOUDKIT_ENVIRONMENT: production` + +This is optional and recommended only for production deployments with real user data. + +### 3. Verify Setup + +#### Option 1: Manual Trigger (Recommended) + +1. Go to: **Actions → Scheduled CloudKit Sync** +2. Click **Run workflow** +3. Select branch: `main` +4. Click **Run workflow** +5. Monitor the run for errors + +#### Option 2: Wait for Scheduled Run + +The workflow runs automatically every 12 hours at: +- 00:00 UTC (midnight) +- 12:00 UTC (noon) + +### 4. Monitor Sync Status + +- **Actions tab:** View workflow run history and logs +- **Email notifications:** GitHub sends emails on workflow failures (configure in Settings → Notifications) +- **Status badge (optional):** Add to README.md: + ```markdown + ![CloudKit Sync](https://github.com/brightdigit/BushelCloud-Schedule/actions/workflows/cloudkit-sync.yml/badge.svg) + ``` + +## Troubleshooting + +### Authentication Failed + +**Error:** `AUTHENTICATION_FAILED` or credential errors + +**Solution:** +- Verify `CLOUDKIT_KEY_ID` matches the key in CloudKit Dashboard +- Ensure `CLOUDKIT_PRIVATE_KEY` includes header/footer lines +- Check for extra whitespace or newlines in secret values +- Confirm the PEM format is correct (use `-----BEGIN PRIVATE KEY-----`, not `-----BEGIN EC PRIVATE KEY-----`) + +### Container Not Found + +**Error:** `Cannot find container` + +**Solution:** +- Verify container ID: `iCloud.com.brightdigit.Bushel` +- Ensure your Apple Developer account has access to this container +- Check that the S2S key has permissions for this container + +### Quota Exceeded + +**Error:** `QUOTA_EXCEEDED` + +**Solution:** +- This is expected if the workflow runs too frequently +- The workflow respects default fetch intervals to prevent this +- Do not use manual triggers more than once per hour + +### Build Failures + +**Error:** Swift build errors + +**Solution:** +- Check if dependencies are accessible (MistKit, BushelKit) +- Verify Package.resolved is committed to repository +- Review workflow logs for specific compilation errors +- Try clearing the cache: Delete the cache key in Actions → Caches + +### Network Timeout + +**Error:** Timeout during data fetch or sync + +**Solution:** +- External data sources may be temporarily unavailable +- The workflow will retry on the next scheduled run +- Check status of data sources: ipsw.me, xcodereleases.com, etc. + +## Security Best Practices + +1. **Never commit `.pem` files to version control** +2. **Rotate keys every 90 days** +3. **Audit key usage in CloudKit Dashboard regularly** +4. **Revoke compromised keys immediately** +5. **Consider separate keys for production** (optional, for enhanced security when handling real user data) + +## Updating Secrets + +### Rotating Keys (Recommended Every 90 Days) + +1. Create a new S2S key in CloudKit Dashboard +2. Update GitHub secrets with new values: + - Go to Settings → Secrets and variables → Actions + - Click on `CLOUDKIT_KEY_ID` → Update secret + - Click on `CLOUDKIT_PRIVATE_KEY` → Update secret +3. Test with a manual workflow run +4. Revoke the old key in CloudKit Dashboard (only after confirming new key works) + +**Tip**: The same key works for both development and production, so you only need to update it once. + +## Performance & Cost + +### Resource Usage + +- **Estimated runtime:** + - First run (cache miss): 8-12 minutes + - Subsequent runs (cache hit): 2-4 minutes +- **Frequency:** 2 runs per day = 60 runs per month +- **GitHub Actions usage:** ~120-240 Linux minutes per month +- **Cost:** Well within GitHub free tier (2,000 minutes/month) + +### Build Caching + +The workflow caches the Swift build directory (`.build`) to speed up subsequent runs: +- Cache key: Based on `Package.resolved` file hash +- Cache invalidation: Automatic when dependencies change +- Cache size: ~50-100 MB + +To clear the cache: +1. Go to: **Actions → Caches** +2. Find caches starting with `Linux-swift-build-` +3. Click delete icon + +## CloudKit Considerations + +### Development vs Production + +This workflow currently uses the **development** CloudKit environment: +- Changes don't affect production data +- Free API calls for public database +- Ideal for testing and demos +- Uses the same S2S key as production (environment is selected via `CLOUDKIT_ENVIRONMENT`) + +**Switching to Production** (when ready): + +Simply change the environment variable in `.github/workflows/cloudkit-sync.yml`: + +```yaml +- name: Run CloudKit sync + env: + CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} + CLOUDKIT_PRIVATE_KEY: ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + CLOUDKIT_ENVIRONMENT: production # ← Change from 'development' to 'production' + CLOUDKIT_CONTAINER_ID: iCloud.com.brightdigit.Bushel +``` + +**Important**: The same key works for both environments. The environment parameter tells CloudKit which database to use. + +**Recommended Approach**: Create a separate workflow file (e.g., `cloudkit-sync-production.yml`) for production syncs with manual trigger only (`workflow_dispatch`). This prevents accidental production changes and allows you to test workflow modifications in development first. + +### Data Sources + +The sync fetches from these sources (with default fetch intervals): +- **ipsw.me** (12 hours) - macOS restore images +- **TheAppleWiki** (12 hours) - macOS restore images +- **Apple MESU** (1 hour) - macOS restore images signing status +- **Mr. Macintosh** (12 hours) - macOS restore images +- **xcodereleases.com** (12 hours) - Xcode versions +- **swiftversion.net** (12 hours) - Swift versions + +The workflow respects these intervals to avoid overwhelming data sources. + +## Advanced Configuration + +### Changing Sync Frequency + +Edit `.github/workflows/cloudkit-sync.yml`: + +```yaml +schedule: + - cron: '0 0 * * *' # Once daily at midnight UTC + - cron: '0 */6 * * *' # Every 6 hours + - cron: '0 0 * * 0' # Weekly on Sundays +``` + +### Changing CloudKit Environment + +The workflow uses the `CLOUDKIT_ENVIRONMENT` environment variable: + +```yaml +env: + CLOUDKIT_ENVIRONMENT: development # or 'production' +``` + +Or override with environment variable: + +```yaml +export CLOUDKIT_ENVIRONMENT=production +BIN_PATH=$(swift build -c release --show-bin-path) +"$BIN_PATH/bushel-cloud" sync --verbose +``` + +**Valid values**: `development`, `production` (case-insensitive) + +### Sync Specific Record Types Only + +Add flags to the sync command in the workflow: + +```yaml +BIN_PATH=$(swift build -c release --show-bin-path) +"$BIN_PATH/bushel-cloud" sync \ + --verbose \ + --restore-images-only # Or --xcode-only, --swift-only +``` + +### Force Fetch (Ignore Intervals) + +Add `--force` flag to bypass fetch throttling: + +```yaml +BIN_PATH=$(swift build -c release --show-bin-path) +"$BIN_PATH/bushel-cloud" sync \ + --verbose \ + --force # Fetch fresh data regardless of intervals +``` + +**Warning:** This increases load on external data sources and may trigger rate limits. + +## Questions or Issues? + +- Review project documentation: [CLAUDE.md](../CLAUDE.md) +- Check S2S authentication details: [.claude/s2s-auth-details.md](../.claude/s2s-auth-details.md) +- File an issue: https://github.com/brightdigit/BushelCloud-Schedule/issues diff --git a/Examples/BushelCloud/.github/SECRETS_SETUP.md b/Examples/BushelCloud/.github/SECRETS_SETUP.md new file mode 100644 index 00000000..4eeb847c --- /dev/null +++ b/Examples/BushelCloud/.github/SECRETS_SETUP.md @@ -0,0 +1,106 @@ +# GitHub Secrets Setup Checklist + +This file lists exactly what secrets you need to configure for the scheduled CloudKit sync workflow. + +## Prerequisites + +Before adding secrets, you need a CloudKit Server-to-Server key: + +1. Visit https://icloud.developer.apple.com/dashboard/ +2. Select container: `iCloud.com.brightdigit.Bushel` +3. Navigate to: **API Access → Server-to-Server Keys** +4. Click **+** to create a new key +5. Download the `.pem` file (you can only download once!) +6. Copy the Key ID (32-character hex string) + +## Required Secrets + +You need to add **2 secrets** to your GitHub repository: + +### 1. CLOUDKIT_KEY_ID + +**Where to add:** +- Repository → Settings → Secrets and variables → Actions → New repository secret + +**Secret configuration:** +- **Name:** `CLOUDKIT_KEY_ID` +- **Value:** Your 32-character key ID from CloudKit Dashboard +- **Example:** `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` + +### 2. CLOUDKIT_PRIVATE_KEY + +**Where to add:** +- Repository → Settings → Secrets and variables → Actions → New repository secret + +**Secret configuration:** +- **Name:** `CLOUDKIT_PRIVATE_KEY` +- **Value:** Full contents of your `.pem` file + +**Getting the PEM content:** + +Option A - Using terminal: +```bash +cat ~/Downloads/AuthKey_XXXXXXXXXX.pem | pbcopy +``` + +Option B - Using text editor: +1. Open the `.pem` file in a text editor +2. Copy **everything** including the header and footer lines: +``` +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg... +[multiple lines of base64 encoded data] +... +-----END PRIVATE KEY----- +``` + +**Important:** Make sure to include the `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` lines! + +## Verification Checklist + +After adding secrets, verify: + +- [ ] Secret `CLOUDKIT_KEY_ID` exists in repository secrets +- [ ] Secret `CLOUDKIT_PRIVATE_KEY` exists in repository secrets +- [ ] PEM content includes header and footer lines +- [ ] No extra whitespace or line breaks in key ID +- [ ] Original `.pem` file stored securely (not in git!) + +## Testing the Setup + +1. Go to: **Actions → Scheduled CloudKit Sync** +2. Click **Run workflow** +3. Select branch: `main` (or current branch) +4. Click **Run workflow** +5. Monitor the run - it should complete successfully + +If you see authentication errors, double-check: +- Key ID matches exactly (no typos) +- PEM content is complete (including headers) +- No extra spaces or formatting issues + +## Important Notes + +- **One key for both environments:** The same key works for development AND production CloudKit environments +- **Environment selection:** The environment (dev/prod) is controlled by `CLOUDKIT_ENVIRONMENT` variable in the workflow file, not by the key +- **Security:** Never commit the `.pem` file to git +- **Rotation:** Rotate keys every 90 days for security + +## Quick Reference + +| Secret Name | Where to Get It | Format | +|-------------|-----------------|--------| +| `CLOUDKIT_KEY_ID` | CloudKit Dashboard → API Access → Server-to-Server Keys | 32-character hex (e.g., `a1b2c3...`) | +| `CLOUDKIT_PRIVATE_KEY` | Downloaded `.pem` file | Multi-line PEM format with headers | + +## Next Steps + +After secrets are configured: +- Workflow will run automatically every 12 hours +- You can trigger manually anytime via Actions tab +- Check workflow logs to verify successful syncs +- Monitor CloudKit Dashboard to see synced data + +## Need Help? + +See the full setup guide: [CLOUDKIT_SYNC_SETUP.md](CLOUDKIT_SYNC_SETUP.md) diff --git a/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml b/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml new file mode 100644 index 00000000..84e3ab09 --- /dev/null +++ b/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml @@ -0,0 +1,389 @@ +# CloudKit Sync Action +# +# Reusable composite action that syncs macOS restore images, Xcode versions, and Swift +# versions to CloudKit using the bushel-cloud CLI tool. Designed for both development +# and production environments with comprehensive validation and reporting. +# +# ## What This Action Does +# +# 1. **Obtains Binary** (optimized with fallback): +# - Fast path: Downloads pre-built binary from artifact cache (~5 seconds) +# - Fallback: Builds fresh binary if artifact expired (~2 minutes) +# - Why: Artifact retention is 90 days; fallback ensures reliability +# +# 2. **Validates Configuration**: +# - Checks all required secrets are present +# - Validates PEM format (headers, footers, base64 encoding) +# - Prevents runtime failures from malformed credentials +# +# 3. **Syncs Data to CloudKit**: +# - Fetches from multiple sources (IPSW, AppleDB, MESU, VirtualBuddy TSS) +# - Deduplicates and merges records +# - Uploads in batches (200 operations per request) +# - Uses Server-to-Server authentication (no iCloud user required) +# +# 4. **Exports and Reports** (optional, controlled by enable-export input): +# - Exports CloudKit data to JSON +# - Generates summary with record counts and signing status +# - Uploads artifacts for audit and debugging +# - Currently limited to 200 records per type (see issue #17 for pagination) +# +# ## Key Design Decisions +# +# **Binary Caching Strategy**: +# - Pre-building saves ~2 minutes per sync (important for 3x daily schedule) +# - Fallback ensures robustness when artifacts expire +# - Both paths use identical Swift 6.2 toolchain for consistency +# +# **PEM Validation**: +# - Early validation prevents cryptic authentication errors during sync +# - Common issues: truncated copy/paste, missing headers, whitespace corruption +# - Validation provides actionable error messages before hitting CloudKit API +# +# **Export Toggle**: +# - Development: Export enabled by default for verification and debugging +# - Production: Can be disabled to reduce CI minutes (export optional for prod) +# - Export artifacts retained for 30 days for audit trail +# +# **VirtualBuddy TSS Integration**: +# - Provides real-time Apple signing status for restore images +# - Rate limited: 2 requests per 5 seconds with server-side 12h cache +# - Takes ~2.5-4 minutes for 50 images (acceptable for 8-16 hour sync schedules) +# +# ## Environment Support +# +# This action supports both CloudKit environments: +# - **Development**: For testing schema changes, new data sources, workflow changes +# - **Production**: For public-facing data after testing in development +# +# Each environment requires separate API keys (key-id and private-key inputs). +# +# ## Usage Examples +# +# ### Development Environment (with export) +# ```yaml +# - uses: ./.github/actions/cloudkit-sync +# with: +# environment: development +# container-id: iCloud.com.brightdigit.Bushel +# cloudkit-key-id: ${{ secrets.CLOUDKIT_KEY_ID }} +# cloudkit-private-key: ${{ secrets.CLOUDKIT_PRIVATE_KEY }} +# virtualbuddy-api-key: ${{ secrets.VIRTUALBUDDY_API_KEY }} +# enable-export: 'true' +# ``` +# +# ### Production Environment (export disabled) +# ```yaml +# - uses: ./.github/actions/cloudkit-sync +# with: +# environment: production +# container-id: iCloud.com.brightdigit.Bushel +# cloudkit-key-id: ${{ secrets.CLOUDKIT_KEY_ID_PROD }} +# cloudkit-private-key: ${{ secrets.CLOUDKIT_PRIVATE_KEY_PROD }} +# virtualbuddy-api-key: ${{ secrets.VIRTUALBUDDY_API_KEY }} +# enable-export: 'false' +# ``` +# +# ## Related Workflows +# +# - **cloudkit-sync-dev.yml**: Scheduled sync (3x daily) + auto-trigger after builds +# - **cloudkit-sync-prod.yml**: Manual trigger only (for controlled production deploys) +# - **bushel-cloud-build.yml**: Builds and caches the binary artifact +# +# ## Troubleshooting +# +# **Sync fails with "AUTHENTICATION_FAILED"**: +# - Check cloudkit-key-id matches the key in CloudKit Dashboard +# - Verify cloudkit-private-key contains complete PEM (including BEGIN/END markers) +# - Ensure key has correct permissions in CloudKit Console +# +# **Binary download fails consistently**: +# - Check bushel-cloud-build.yml workflow is running successfully +# - Verify artifact retention hasn't expired (90 days) +# - Fallback build will trigger automatically (adds ~2 minutes) +# +# **Export shows 200 records but more expected**: +# - Known limitation: Export only retrieves first page (see issue #17) +# - Workaround: Run local export with pagination once implemented +# - Does not affect sync operation (only reporting) + +name: 'CloudKit Sync Action' +description: 'Reusable action for syncing data to CloudKit with export reporting' + +inputs: + environment: + description: 'CloudKit environment (development or production)' + required: true + container-id: + description: 'CloudKit container ID' + required: true + cloudkit-key-id: + description: 'CloudKit S2S key ID' + required: true + cloudkit-private-key: + description: 'CloudKit S2S private key (PEM content)' + required: true + virtualbuddy-api-key: + description: 'VirtualBuddy TSS API key' + required: true + enable-export: + description: 'Run export after sync and generate reports' + required: false + default: 'true' + +runs: + using: "composite" + steps: + - name: Download pre-built binary (if available) + id: download-binary + uses: dawidd6/action-download-artifact@v3 + continue-on-error: true # Don't fail if artifact is missing + with: + workflow: bushel-cloud-build.yml + workflow_conclusion: success + name: bushel-cloud-binary + path: ./binary + branch: ${{ github.ref_name }} + + - name: Build binary (fallback if artifact unavailable) + if: steps.download-binary.outcome != 'success' + shell: bash + run: | + echo "⚠️ Pre-built binary not available (may have expired after retention period)" + echo "Building fresh binary as fallback..." + + # Build using Swift 6.2 (matches build workflow) + docker run --rm -v "$PWD:/workspace" -w /workspace swift:6.2-noble \ + swift build -c release --static-swift-stdlib + + # Copy binary to expected location + mkdir -p ./binary + cp .build/release/bushel-cloud ./binary/ + + echo "✅ Binary built successfully" + + - name: Validate binary availability + shell: bash + run: | + if [ ! -f ./binary/bushel-cloud ]; then + echo "❌ Error: Binary not found at ./binary/bushel-cloud" + exit 1 + fi + + # Log source for debugging + if [ "${{ steps.download-binary.outcome }}" == "success" ]; then + echo "✅ Using pre-built binary (fast path)" + else + echo "✅ Using freshly-built binary (fallback path)" + fi + + ls -lh ./binary/bushel-cloud + + - name: Make binary executable + shell: bash + run: chmod +x ./binary/bushel-cloud + + - name: Validate required secrets + shell: bash + env: + VIRTUALBUDDY_API_KEY: ${{ inputs.virtualbuddy-api-key }} + run: | + if [ -z "$VIRTUALBUDDY_API_KEY" ]; then + echo "❌ Error: VIRTUALBUDDY_API_KEY is not set" + echo "Please add VIRTUALBUDDY_API_KEY to repository secrets" + exit 1 + fi + echo "✅ All required secrets are present" + + - name: Validate PEM format + shell: bash + env: + CLOUDKIT_PRIVATE_KEY: ${{ inputs.cloudkit-private-key }} + run: | + echo "Validating PEM format..." + + # Check for required PEM headers/footers (using here-string to avoid exposing secrets) + if ! grep -q "BEGIN.*PRIVATE KEY" <<< "$CLOUDKIT_PRIVATE_KEY"; then + echo "❌ Error: PEM header not found" + echo "" + echo "Expected format:" + echo " -----BEGIN PRIVATE KEY-----" + echo " [base64 encoded key]" + echo " -----END PRIVATE KEY-----" + echo "" + echo "Common issues:" + echo " - Missing BEGIN/END markers" + echo " - Extra whitespace or newlines" + echo " - Copy/paste truncation" + echo "" + exit 1 + fi + + if ! grep -q "END.*PRIVATE KEY" <<< "$CLOUDKIT_PRIVATE_KEY"; then + echo "❌ Error: PEM footer not found" + echo "Ensure the complete PEM file was copied (including footer)" + exit 1 + fi + + # Check for base64 content between headers (using here-string to avoid exposing secrets) + PEM_CONTENT=$(sed -n '/BEGIN/,/END/p' <<< "$CLOUDKIT_PRIVATE_KEY" | grep -v "BEGIN\|END") + if [ -z "$PEM_CONTENT" ]; then + echo "❌ Error: PEM file appears empty (no key data between headers)" + exit 1 + fi + + # Validate base64 encoding (using here-string to avoid exposing secrets) + if ! base64 -d >/dev/null 2>&1 <<< "$PEM_CONTENT"; then + echo "❌ Error: PEM content is not valid base64" + echo "The key may be corrupted or in the wrong format" + exit 1 + fi + + echo "✅ PEM format validation passed" + + - name: Run CloudKit sync with change tracking + shell: bash + env: + CLOUDKIT_KEY_ID: ${{ inputs.cloudkit-key-id }} + CLOUDKIT_PRIVATE_KEY: ${{ inputs.cloudkit-private-key }} + CLOUDKIT_ENVIRONMENT: ${{ inputs.environment }} + CLOUDKIT_CONTAINER_ID: ${{ inputs.container-id }} + VIRTUALBUDDY_API_KEY: ${{ inputs.virtualbuddy-api-key }} + BUSHEL_SYNC_JSON_OUTPUT_FILE: sync-result.json + run: | + echo "Starting CloudKit sync with change tracking..." + echo "Container: $CLOUDKIT_CONTAINER_ID" + echo "Environment: $CLOUDKIT_ENVIRONMENT" + + # Run sync with JSON output written directly to file + # All verbose logs go to console (will be captured in workflow logs) + ./binary/bushel-cloud sync \ + --verbose \ + --container-identifier "$CLOUDKIT_CONTAINER_ID" + + # Parse sync results using jq + RESTORE_CREATED=$(jq '.restoreImages.created' sync-result.json) + RESTORE_UPDATED=$(jq '.restoreImages.updated' sync-result.json) + RESTORE_FAILED=$(jq '.restoreImages.failed' sync-result.json) + + XCODE_CREATED=$(jq '.xcodeVersions.created' sync-result.json) + XCODE_UPDATED=$(jq '.xcodeVersions.updated' sync-result.json) + XCODE_FAILED=$(jq '.xcodeVersions.failed' sync-result.json) + + SWIFT_CREATED=$(jq '.swiftVersions.created' sync-result.json) + SWIFT_UPDATED=$(jq '.swiftVersions.updated' sync-result.json) + SWIFT_FAILED=$(jq '.swiftVersions.failed' sync-result.json) + + # Calculate totals manually + TOTAL_CREATED=$((RESTORE_CREATED + XCODE_CREATED + SWIFT_CREATED)) + TOTAL_UPDATED=$((RESTORE_UPDATED + XCODE_UPDATED + SWIFT_UPDATED)) + TOTAL_FAILED=$((RESTORE_FAILED + XCODE_FAILED + SWIFT_FAILED)) + + # Generate summary showing changes (not just totals) + cat > sync-summary.md <> $GITHUB_STEP_SUMMARY + + echo "✅ Sync complete" + + - name: Run export (optional data audit) + if: inputs.enable-export == 'true' + shell: bash + env: + CLOUDKIT_KEY_ID: ${{ inputs.cloudkit-key-id }} + CLOUDKIT_PRIVATE_KEY: ${{ inputs.cloudkit-private-key }} + CLOUDKIT_ENVIRONMENT: ${{ inputs.environment }} + CLOUDKIT_CONTAINER_ID: ${{ inputs.container-id }} + run: | + echo "Exporting CloudKit data for audit trail..." + + # Export to JSON + ./binary/bushel-cloud export \ + --output "cloudkit-export-${{ inputs.environment }}.json" \ + --pretty \ + --verbose \ + --container-identifier "$CLOUDKIT_CONTAINER_ID" + + # Parse JSON to extract counts + RESTORE_COUNT=$(jq '.restoreImages | length' "cloudkit-export-${{ inputs.environment }}.json") + XCODE_COUNT=$(jq '.xcodeVersions | length' "cloudkit-export-${{ inputs.environment }}.json") + SWIFT_COUNT=$(jq '.swiftVersions | length' "cloudkit-export-${{ inputs.environment }}.json") + TOTAL_COUNT=$((RESTORE_COUNT + XCODE_COUNT + SWIFT_COUNT)) + + # Count signed restore images + SIGNED_COUNT=$(jq '[.restoreImages[] | select(.fields.isSigned == "int64(1)")] | length' "cloudkit-export-${{ inputs.environment }}.json" || echo "0") + + # Generate markdown summary + cat > export-summary.md <> $GITHUB_STEP_SUMMARY + + echo "✅ Export complete with ${TOTAL_COUNT} total records" + + - name: Upload sync results + if: always() + uses: actions/upload-artifact@v4 + with: + name: sync-results-${{ inputs.environment }} + path: | + sync-result.json + sync-summary.md + retention-days: 7 + + - name: Upload export artifacts + if: inputs.enable-export == 'true' + uses: actions/upload-artifact@v4 + with: + name: cloudkit-export-${{ inputs.environment }} + path: | + cloudkit-export-${{ inputs.environment }}.json + export-summary.md + retention-days: 30 diff --git a/Examples/BushelCloud/.github/workflows/BushelCloud.yml b/Examples/BushelCloud/.github/workflows/BushelCloud.yml new file mode 100644 index 00000000..a5e7542b --- /dev/null +++ b/Examples/BushelCloud/.github/workflows/BushelCloud.yml @@ -0,0 +1,185 @@ +name: BushelCloud +on: + push: + branches-ignore: + - '*WIP' +env: + PACKAGE_NAME: BushelCloud +jobs: + build-ubuntu: + name: Build on Ubuntu + runs-on: ubuntu-latest + container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + matrix: + os: [noble, jammy] + swift: + - version: "6.2" + - version: "6.3" + nightly: true + + steps: + - uses: actions/checkout@v4 + + - name: Update Package.swift to use remote MistKit branch + run: | + sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "main")|g' Package.swift + rm -f Package.resolved + + - uses: brightdigit/swift-build@v1.4.2 + with: + skip-package-resolved: true + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files + with: + fail-on-empty-output: true + minimum-coverage: 70 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && '-nightly' || '' }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + # build-windows: + # name: Build on Windows + # runs-on: ${{ matrix.runs-on }} + # if: "!contains(github.event.head_commit.message, 'ci skip')" + # strategy: + # fail-fast: false + # matrix: + # runs-on: [windows-2022, windows-2025] + # swift: + # - version: swift-6.2-release + # build: 6.2-RELEASE + # steps: + # - uses: actions/checkout@v4 + # - uses: brightdigit/swift-build@v1.4.2 + # with: + # windows-swift-version: ${{ matrix.swift.version }} + # windows-swift-build: ${{ matrix.swift.build }} + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v5 + # with: + # fail_ci_if_error: true + # flags: swift-${{ matrix.swift.version }},windows + # verbose: true + # token: ${{ secrets.CODECOV_TOKEN }} + # os: windows + # swift_project: BushelCloud-Package + build-macos: + name: Build on macOS + env: + PACKAGE_NAME: BushelCloud + runs-on: ${{ matrix.runs-on }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + fail-fast: false + matrix: + include: + # SPM Build Matrix + - runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + + # macOS Build Matrix + - type: macos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + + # iOS Build Matrix + - type: ios + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + deviceName: "iPhone 17 Pro" + osVersion: "26.0.1" + download-platform: true + + # watchOS Build Matrix + - type: watchos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + deviceName: "Apple Watch Ultra 3 (49mm)" + osVersion: "26.0" + download-platform: true + + # tvOS Build Matrix + - type: tvos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + deviceName: "Apple TV" + osVersion: "26.0" + download-platform: true + + # visionOS Build Matrix + - type: visionos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + deviceName: "Apple Vision Pro" + osVersion: "26.0" + download-platform: true + + steps: + - uses: actions/checkout@v4 + + - name: Update Package.swift to use remote MistKit branch + run: | + sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "main")|g' Package.swift + rm -f Package.resolved + + - name: Build and Test + uses: brightdigit/swift-build@v1.4.2 + with: + scheme: ${{ env.PACKAGE_NAME }}-Package + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + skip-package-resolved: true + + # Coverage Steps + - name: Process Coverage + uses: sersoft-gmbh/swift-coverage-action@v4 + with: + minimum-coverage: 70 + + - name: Upload Coverage + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + lint: + name: Linting + if: "!contains(github.event.head_commit.message, 'ci skip')" + runs-on: ubuntu-latest + needs: [build-ubuntu, build-macos] # , build-windows] + env: + MINT_PATH: .mint/lib + MINT_LINK_PATH: .mint/bin + steps: + - uses: actions/checkout@v4 + - name: Cache mint + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache + with: + path: | + .mint + Mint + key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} + restore-keys: | + ${{ runner.os }}-mint- + - name: Install mint + if: steps.cache-mint.outputs.cache-hit == '' + run: | + git clone https://github.com/yonaskolb/Mint.git + cd Mint + swift run mint install yonaskolb/mint + - name: Lint + run: | + set -e + ./Scripts/lint.sh diff --git a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml new file mode 100644 index 00000000..50188355 --- /dev/null +++ b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml @@ -0,0 +1,86 @@ +name: Build bushel-cloud Binary + +on: + # Build on code changes to main branch + push: + branches: + - main + + # Allow manual trigger + workflow_dispatch: + + # Build on PRs for validation + pull_request: + +# Prevent concurrent builds +# Why cancel-in-progress? +# - Newer code changes supersede older builds +# - Saves CI minutes by canceling outdated builds +# - Each branch gets independent builds via ${{ github.ref }} +concurrency: + group: bushel-cloud-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build bushel-cloud + runs-on: ubuntu-latest + container: swift:6.2-noble + timeout-minutes: 20 + + permissions: + contents: read # Read repository code + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Verify Swift version + run: | + swift --version + swift package --version + + - name: Build bushel-cloud executable + id: build + run: | + swift build -c release --static-swift-stdlib + BIN_PATH=$(swift build -c release --static-swift-stdlib --show-bin-path) + echo "bin-path=$BIN_PATH" >> $GITHUB_OUTPUT + echo "Binary location: $BIN_PATH/bushel-cloud" + ls -lh "$BIN_PATH/bushel-cloud" + + - name: Prepare binary for upload + id: prepare + run: | + # Create a clean directory for the artifact + mkdir -p artifact + + # Copy binary to artifact directory + cp "${{ steps.build.outputs.bin-path }}/bushel-cloud" artifact/ + + # Create build metadata + cat > artifact/build-metadata.json < + +# Watch logs of a running workflow +gh run watch +``` + +## Architecture + +### Modular Architecture with BushelKit + +Starting with v0.0.1, BushelCloud uses **BushelKit** as a modular foundation: + +**BushelKit** (`Packages/BushelKit/`): +- `BushelFoundation` - Core domain models (RestoreImageRecord, XcodeVersionRecord, SwiftVersionRecord) +- `BushelUtilities` - Shared utilities (FormattingHelpers, JSONDecoder extensions) +- `BushelLogging` - Logging abstractions + +**BushelCloudKit** (`Sources/BushelCloudKit/`): +- Extends BushelKit models with CloudKit integration +- `Extensions/*+CloudKit.swift` - CloudKitRecord protocol conformance +- `DataSources/` - API fetchers (IPSW, AppleDB, MESU, etc.) +- `CloudKit/` - SyncEngine and service layer + +**Dependency Flow**: +``` +BushelCloudCLI → BushelCloudKit → BushelFoundation → BushelUtilities → MistKit +``` + +**Why This Architecture**: +- Domain models reusable across projects (BushelKit can evolve independently) +- CloudKit logic isolated to extensions (clean separation) +- Shared utilities promote consistency +- Testing each layer independently + +### Core Data Flow + +The application follows a pipeline architecture: + +``` +External Data Sources → DataSourcePipeline → CloudKitService → CloudKit + ↓ + (deduplication & merging) +``` + +**Three-Phase Sync Process**: +1. **Fetch**: `DataSourcePipeline` fetches from multiple external APIs in parallel +2. **Transform**: Records are deduplicated and references resolved +3. **Upload**: `BushelCloudKitService` batches operations and uploads to CloudKit + +### Key Components + +**CloudKit Integration** (`Sources/BushelCloud/CloudKit/`): +- `BushelCloudKitService.swift` - Server-to-Server auth setup and batch operations wrapper +- `SyncEngine.swift` - Orchestrates the full sync process from fetch to upload +- `CloudKitFieldMapping.swift` - Type conversion helpers between Swift and CloudKit FieldValue +- `RecordManaging+Query.swift` - Query extensions for fetching records + +**Data Sources** (`Sources/BushelCloud/DataSources/`): +- `DataSourcePipeline.swift` - Coordinates fetching from all sources with metadata tracking +- Individual fetchers: `IPSWFetcher`, `AppleDBFetcher`, `MESUFetcher`, `XcodeReleasesFetcher`, etc. + +**Models** (`Packages/BushelKit/Sources/BushelFoundation/`): +- `RestoreImageRecord` - macOS restore images (uses `URL` and `Int` types) +- `XcodeVersionRecord` - Xcode releases with CKReference relationships +- `SwiftVersionRecord` - Swift compiler versions +- `DataSourceMetadata` - Fetch metadata with timestamp tracking + +**CloudKit Extensions** (`Sources/BushelCloudKit/Extensions/`): +- `RestoreImageRecord+CloudKit.swift` - Implements CloudKitRecord protocol +- `XcodeVersionRecord+CloudKit.swift` - Handles CKReference serialization +- `SwiftVersionRecord+CloudKit.swift` - Basic CloudKit mapping +- `DataSourceMetadata+CloudKit.swift` - Metadata sync record +- `FieldValue+URL.swift` - URL ↔ STRING conversion + +**Commands** (`Sources/BushelCloud/Commands/`): +- CLI commands using swift-argument-parser +- Each command (sync, export, clear, list, status) is a separate file + +### CloudKit Record Relationships + +Records have dependencies that must be uploaded in order: + +``` +SwiftVersion (no dependencies) +RestoreImage (no dependencies) + ↓ + | CKReference (minimumMacOS, swiftVersion) + ↓ +XcodeVersion +``` + +**Upload Order**: SwiftVersion & RestoreImage → XcodeVersion (see `SyncEngine.swift:100`) + +### Data Deduplication + +Multiple sources provide overlapping data. The pipeline deduplicates using: +- **Build Number** as unique key for Restore Images and Xcode Versions +- **Version String** as unique key for Swift Versions +- **Merge Priority**: MESU for signing status, AppleDB for hashes, most recent `sourceUpdatedAt` wins + +See `.claude/implementation-patterns.md` for detailed deduplication logic and code examples. + +### VirtualBuddy TSS API Integration + +**Purpose**: VirtualBuddy provides real-time TSS (Tatsu Signing Status) verification for macOS restore images for virtual machines. + +**API Endpoint**: +``` +GET https://tss.virtualbuddy.app/v1/status?apiKey=&ipsw= +``` + +**Board Config**: Checks `VMA2MACOSAP` (macOS virtual machines) + +**Key Response Fields**: +- `isSigned` (boolean) - true if Apple is signing the build, false otherwise +- `uuid` - Request tracking ID for debugging +- `version` - macOS version (e.g., "15.0") +- `build` - Build number (e.g., "24A5327a") +- `code` - Status code (0 = SUCCESS, 94 = not eligible) +- `message` - Human-readable status message + +**HTTP Status Codes**: +- `200` - Success (returned regardless of signing status) +- `400` - Bad request (invalid IPSW URL) +- `429` - Rate limit exceeded +- `500` - Internal server error + +**Rate Limits & Caching**: +- **Rate limit**: 2 requests per 5 seconds +- **Server-side CDN cache**: 12 hours (to avoid Apple TSS rate limiting) +- **Client-side implementation**: Random delays of 2.5-3.5 seconds with 1-second tolerance between requests + +**Implementation Details**: +- **File**: `Sources/BushelCloudKit/DataSources/VirtualBuddyFetcher.swift` +- **Integration**: Enriches RestoreImageRecord with real-time signing status after other data sources +- **Error handling**: HTTP 429 errors are logged; original record preserved on any error +- **Progress tracking**: Shows "X/Y images checked" during sync +- **Performance**: ~2.5-4 minutes for 50 images (acceptable for 8-16 hour sync schedules) + +**Deduplication Priority**: +- VirtualBuddy is an **authoritative source** for `isSigned` status (along with MESU) +- Takes precedence over other sources when merging duplicate records +- See `DataSourcePipeline.swift:429-447` for merge logic + +**Example Response (Signed)**: +```json +{ + "uuid": "67919BEC-F793-4544-A5E6-152EE435DCA6", + "version": "15.0", + "build": "24A5327a", + "code": 0, + "message": "SUCCESS", + "isSigned": true +} +``` + +**Example Response (Unsigned)**: +```json +{ + "uuid": "02A12F2F-CE0E-4FBF-8155-884B8D9FD5CB", + "version": "15.1", + "build": "24B5024e", + "code": 94, + "message": "This device isn't eligible for the requested build.", + "isSigned": false +} +``` + +### Logging + +The project uses structured logging with `BushelLogger` (wrapping `os.Logger`): +- **Standard logs**: Progress and results +- **Verbose logs** (`--verbose`): MistKit API calls, batch details +- **Subsystems**: `.sync`, `.cloudKit`, `.dataSource` + +## Git Subrepo Development + +BushelKit is embedded as a git subrepo for rapid development during the migration phase. + +### Configuration +- **Remote:** `git@github.com:brightdigit/BushelKit.git` +- **Branch:** `bushelcloud` +- **Location:** `Packages/BushelKit/` + +### Making Changes to BushelKit + +```bash +# 1. Edit files in Packages/BushelKit/ +vim Packages/BushelKit/Sources/BushelFoundation/RestoreImageRecord.swift + +# 2. Commit changes in BushelCloud repository +git add Packages/BushelKit/ +git commit -m "Update BushelKit: add field documentation" + +# 3. Push changes to BushelKit repository +git subrepo push Packages/BushelKit +``` + +### Pulling Updates from BushelKit + +```bash +git subrepo pull Packages/BushelKit +``` + +### When to Switch to Remote Dependency + +| Development Stage | Approach | Package.swift | +|-------------------|----------|---------------| +| Now (active migration) | Git subrepo (local path) | `.package(path: "Packages/BushelKit")` | +| After BushelKit v2.0 stable | Remote dependency | `.package(url: "https://github.com/brightdigit/BushelKit.git", from: "2.0.0")` | + +**Benefits of Subrepo Now:** +- Edit BushelKit and BushelCloud together +- Test integration immediately +- No version coordination overhead + +**Migration to Remote:** +1. Tag stable BushelKit version +2. Update `Package.swift` to use URL dependency +3. Remove `Packages/BushelKit/` directory +4. Use standard SPM workflow + +### Best Practices +- Commit BushelKit changes separately from BushelCloud changes +- Push to subrepo after each logical change +- Pull before starting new work +- Test both repositories after changes + +## Development Essentials + +### Swift 6 Configuration + +The project uses strict Swift 6 concurrency checking (see `Package.swift:10-78`): +- Full typed throws +- Complete strict concurrency checking +- Noncopyable generics, variadic generics +- Actor data race checks +- **All types are `Sendable`** + +**When adding code**: Ensure all new types conform to `Sendable` and use `async/await` patterns consistently. + +### Type Design Decisions + +#### Int vs Int64 for File Sizes + +**Decision:** All models use `Int` for byte counts (fileSize fields) + +**Rationale:** +- All supported platforms are 64-bit (macOS 14+, iOS 17+, watchOS 10+) +- On 64-bit systems: `Int` == `Int64` (same size and range) +- Swift convention: use `Int` for counts, sizes, and indices +- CloudKit automatically converts via `.int64(fileSize)` + +**Safety Analysis:** +- Largest image file: ~15 GB +- `Int.max` on 64-bit: 9,223,372,036,854,775,807 bytes (~9 exabytes) +- **No overflow risk** for any realistic file size + +**CloudKit Integration:** +```swift +// Write to CloudKit +fields["fileSize"] = .int64(record.fileSize) // Auto-converts Int → Int64 + +// Read from CloudKit +let size: Int? = recordInfo.fields["fileSize"]?.intValue // Returns Int +``` + +#### URL Type for Download Links + +**Decision:** Models use `URL` (not `String`) for download links + +**Benefits:** +- Type safety at compile time +- URL component access (scheme, host, path, query) +- Automatic validation on creation +- Custom `FieldValue(url:)` extension handles CloudKit STRING conversion + +**CloudKit Integration:** +```swift +// Extension: Sources/BushelCloudKit/Extensions/FieldValue+URL.swift +public extension FieldValue { + init(url: URL) { + self = .string(url.absoluteString) + } + + var urlValue: URL? { + if case .string(let value) = self { + return URL(string: value) + } + return nil + } +} +``` + +**Tests:** See `Tests/BushelCloudTests/Extensions/FieldValueURLTests.swift` (13 test methods) + +### CloudKitRecord Protocol + +All domain models conform to `CloudKitRecord`: + +```swift +protocol CloudKitRecord { + static var cloudKitRecordType: String { get } + func toCloudKitFields() -> [String: FieldValue] + static func from(recordInfo: RecordInfo) -> Self? + static func formatForDisplay(_ recordInfo: RecordInfo) -> String +} +``` + +**When adding a new record type**: +1. Create model struct in `Models/` +2. Implement `CloudKitRecord` conformance +3. Add to `BushelCloudKitService.recordTypes` (line 19) +4. Update CloudKit schema in Dashboard or via `cktool` + +### Date Handling + +CloudKit dates use **milliseconds since epoch** (not seconds). MistKit's `FieldValue.date()` handles conversion automatically: + +```swift +fields["releaseDate"] = .date(Date()) // ✅ Auto-converted to milliseconds +``` + +### Boolean Fields + +CloudKit has no native boolean type. Use `INT64` with 0/1: + +```swift +// In schema +"isSigned" INT64 QUERYABLE, // 0 = false, 1 = true + +// In Swift +fields["isSigned"] = .int64(record.isSigned ? 1 : 0) + +// Reading back +if case .int64(let value) = fields["isSigned"] { + let isSigned = value == 1 +} +``` + +### Reference Fields + +CloudKit references use record names (not IDs): + +```swift +// Creating a reference +fields["minimumMacOS"] = .reference( + FieldValue.Reference(recordName: "RestoreImage-23C71") +) + +// Reading a reference +if case .reference(let ref) = fieldValue { + let recordName = ref.recordName +} +``` + +**Upload order matters**: Upload referenced records before records that reference them, or you'll get `VALIDATING_REFERENCE_ERROR`. + +### Batch Operations + +CloudKit enforces a **200 operations per request** limit. Operations are automatically chunked: + +```swift +let batchSize = 200 +let batches = operations.chunked(into: batchSize) +for batch in batches { + let results = try await service.modifyRecords(batch) +} +``` + +See `.claude/s2s-auth-details.md` for detailed batch operation examples and error handling. + +### Error Handling + +- `BushelCloudKitError` enum defines project-specific errors +- MistKit operations throw `CloudKitError` for API failures +- Use `RecordInfo.isError` to detect partial batch failures +- Verbose mode logs error details (serverErrorCode, reason) + +**Common error codes**: +- `ACCESS_DENIED` - Check schema permissions (_creator + _icloud) +- `AUTHENTICATION_FAILED` - Invalid key ID or signature +- `CONFLICT` - Use `.forceReplace` instead of `.create` +- `VALIDATING_REFERENCE_ERROR` - Referenced record doesn't exist + +### Testing Data Sources + +Individual fetchers can be tested directly: + +```swift +let fetcher = IPSWFetcher() +let images = try await fetcher.fetch() +``` + +No formal test suite exists (this is a demo project). + +## CloudKit Integration + +### Server-to-Server Authentication Overview + +S2S authentication allows CLI tools to access CloudKit without a signed-in iCloud user. BushelCloud uses ECDSA P-256 private keys: + +```swift +// Initialize with private key from .pem file +let tokenManager = try ServerToServerAuthManager( + keyID: "your-key-id", + pemString: pemFileContents +) + +let service = try CloudKitService( + containerIdentifier: "iCloud.com.company.App", + tokenManager: tokenManager, + environment: .development, + database: .public +) +``` + +**Key setup**: +1. Generate key pair in CloudKit Dashboard +2. Download .pem file to `~/.cloudkit/bushel-private-key.pem` +3. Set permissions: `chmod 600 ~/.cloudkit/bushel-private-key.pem` +4. Set environment variables (see Quick Start section) + +See `.claude/s2s-auth-details.md` for implementation details, security best practices, and troubleshooting. + +### CloudKit Schema Requirements + +The project requires three record types in the public database: + +**RestoreImage**: version, buildNumber, releaseDate, downloadURL, fileSize, sha256Hash, sha1Hash, isSigned (INT64), isPrerelease (INT64), source, notes, sourceUpdatedAt + +**XcodeVersion**: version, buildNumber, releaseDate, downloadURL, isPrerelease (INT64), source, minimumMacOS (REFERENCE), swiftVersion (REFERENCE), notes + +**SwiftVersion**: version, releaseDate, downloadURL, source, notes + +**DataSourceMetadata**: sourceName, recordTypeName, lastFetchedAt, sourceUpdatedAt, recordCount, fetchDurationSeconds, lastError + +**Critical Schema Rules**: +1. Always start schema files with `DEFINE SCHEMA` +2. System fields (`___recordID`, `___createdTimestamp`, etc.) can be included in `.ckdb` schema files but are auto-generated when creating records via API +3. Grant permissions to **both** `_creator` AND `_icloud` for S2S auth +4. Use `INT64` for booleans (0 = false, 1 = true) + +### Basic cktool Commands + +```bash +# Save management token (for schema operations) +xcrun cktool save-token + +# Validate schema +xcrun cktool validate-schema --team-id TEAM_ID --container-id CONTAINER_ID --environment development --file schema.ckdb + +# Import schema +xcrun cktool import-schema --team-id TEAM_ID --container-id CONTAINER_ID --environment development --file schema.ckdb + +# Export current schema +xcrun cktool export-schema --team-id TEAM_ID --container-id CONTAINER_ID --environment development > backup.ckdb +``` + +See `.claude/schema-management.md` for complete cktool reference, schema versioning, CI/CD deployment, and troubleshooting. + +### Common CloudKit Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| `ACCESS_DENIED` | Missing permissions | Grant to both `_creator` and `_icloud` | +| `AUTHENTICATION_FAILED` | Invalid key/PEM | Check `CLOUDKIT_KEY_ID` and `.pem` file | +| `VALIDATING_REFERENCE_ERROR` | Referenced record missing | Upload referenced records first | +| `QUOTA_EXCEEDED` | Too many operations | Reduce batch size or wait | + +## Xcode Development Setup + +### Opening in Xcode + +```bash +# From Terminal +cd /Users/leo/Documents/Projects/BushelCloud +open Package.swift + +# Or: File > Open in Xcode, select Package.swift +``` + +### Scheme Configuration Essentials + +1. **Edit Scheme** (Cmd+Shift+,) +2. **Run → Arguments tab**: + - Add environment variables: + - `CLOUDKIT_CONTAINER_ID`: `iCloud.com.brightdigit.Bushel` + - `CLOUDKIT_KEY_ID`: Your key ID + - `CLOUDKIT_PRIVATE_KEY_PATH`: `$HOME/.cloudkit/bushel-private-key.pem` + - Add arguments for testing: + - `sync --verbose` or `export --output ./export.json --verbose` +3. **Run → Options tab**: + - Set custom working directory to project root + +### Key Debugging Workflows + +**Test data fetching without CloudKit**: +- Use `--dry-run` flag: `.build/debug/bushel-cloud sync --dry-run --verbose` +- Or set breakpoint in `SyncEngine.swift` after `fetchAllData()` to inspect fetched records + +**Debug CloudKit upload**: +- Set breakpoint in `BushelCloudKitService.modifyRecords()` before upload +- Inspect `operations` array and `results` for errors + +**Useful breakpoint locations**: +- `SyncEngine.swift` - `sync()` method start +- `DataSourcePipeline.swift` - `fetchAllData()` to inspect fetched records +- `BushelCloudKitService.swift` - `modifyRecords()` before CloudKit upload +- Individual fetchers - `fetch()` methods for data source issues + +### Common Xcode Issues + +| Issue | Solution | +|-------|----------| +| "Cannot find container" | Verify `CLOUDKIT_CONTAINER_ID` is correct | +| "Authentication failed" | Check `CLOUDKIT_KEY_ID` and `CLOUDKIT_PRIVATE_KEY_PATH` | +| "Module 'MistKit' not found" | Reset package cache or rebuild | +| "Cannot find type 'RecordOperation'" | Clean build folder (Cmd+Shift+K) and rebuild | + +### XcodeGen (Optional) + +Generate Xcode project with pre-configured settings: + +```bash +brew install xcodegen +xcodegen generate +open BushelCloud.xcodeproj +``` + +Note: The `.xcodeproj` is in `.gitignore` - always regenerate rather than committing. + +## Dependencies + +- **MistKit** (local path: `../MistKit`) - CloudKit Web Services client with S2S auth +- **IPSWDownloads** - ipsw.me API wrapper for restore images +- **SwiftSoup** - HTML parsing for web scraping +- **ArgumentParser** - CLI framework +- **swift-log** - Logging infrastructure + +MistKit is the parent package; BushelCloud is an example in `Examples/Bushel/`. + +## CI/CD and Code Quality + +### Linting + +```bash +./Scripts/lint.sh +``` + +**Tools (managed via Mint)**: +- `swift-format@602.0.0` - Code formatting +- `SwiftLint@0.62.2` - Style and convention linting (90+ opt-in rules) +- `periphery@3.2.0` - Unused code detection + +**Configuration**: `.swiftlint.yml`, `.swift-format`, `Mintfile` + +### Testing + +```bash +swift test +``` + +Note: Currently only placeholder tests exist (demo project focused on CloudKit patterns). + +### GitHub Actions Workflows + +- **BushelCloud.yml** - Main CI with multi-platform testing (Ubuntu, Windows, macOS) +- **codeql.yml** - Security analysis +- **claude.yml** - Claude Code integration for issues/PRs +- **claude-code-review.yml** - Automated PR reviews + +### Branch Protection + +The `main` branch requires: +- All 14 status checks passing (multi-platform builds + lint + CodeQL) +- Pull request reviews +- Conversation resolution + +## Important Limitations + +As noted in README.md, this is a **demonstration project** with known limitations: + +- No incremental sync (always fetches all data from external sources) +- No conflict resolution for concurrent updates +- Limited error recovery in batch operations +- **Export pagination**: Export only retrieves first 200 records per type (see Issue #8) + +These are intentional to keep the demo focused on MistKit patterns rather than production robustness. + +**Note on Duplicates**: The sync properly uses `.forceReplace` operations with deterministic record names (based on build numbers), so repeated syncs **update** existing records rather than creating duplicates. + +## Additional Documentation + +For detailed guides on advanced topics, see: + +- **[.claude/schema-management.md](.claude/schema-management.md)** - Complete CloudKit schema management guide + - Schema file format and rules + - Complete cktool command reference + - Schema validation errors and solutions + - Versioning best practices + - CI/CD deployment automation + - Database scope considerations + +- **[.claude/s2s-auth-details.md](.claude/s2s-auth-details.md)** - Server-to-Server authentication implementation + - How S2S authentication works internally + - BushelCloudKitService implementation pattern + - Security best practices and key management + - Common authentication errors and solutions + - Operation types and permissions + - Batch operations with detailed examples + - Testing S2S authentication + - Development vs Production environments + +- **[.claude/implementation-patterns.md](.claude/implementation-patterns.md)** - Implementation patterns and history + - Data source integration pattern with code examples + - Deduplication strategy and merge logic + - AppleDB integration details + - S2S authentication migration history + - Critical issues solved and lessons learned + - Common pitfalls to avoid + - Lessons for building future CloudKit demos diff --git a/Examples/BushelCloud/LICENSE b/Examples/BushelCloud/LICENSE new file mode 100644 index 00000000..575c3767 --- /dev/null +++ b/Examples/BushelCloud/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 BrightDigit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Examples/BushelCloud/LINTING_PROGRESS.md b/Examples/BushelCloud/LINTING_PROGRESS.md new file mode 100644 index 00000000..44bde0ee --- /dev/null +++ b/Examples/BushelCloud/LINTING_PROGRESS.md @@ -0,0 +1,435 @@ +# SwiftLint STRICT Mode Fixes - Progress Document + +## Session Date: 2026-01-02 + +## Objective +Fix 7 specific SwiftLint violation types across the codebase: +1. `explicit_acl` - Add access control keywords +2. `explicit_top_level_acl` - Add access control to top-level types +3. `type_contents_order` - Reorganize type members in correct order +4. `multiline_arguments_brackets` - Move closing brackets to new lines +5. `line_length` - Break lines over 108 characters +6. `conditional_returns_on_newline` - Move return statements to new lines +7. `discouraged_optional_boolean` - **SKIPPED** per user decision + +## Progress Summary + +### ✅ Phase 1: High-Impact Files (5/5 Complete) ✨ + +#### Completed Files: + +**1. SyncEngine.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to properties (cloudKitService, pipeline) +- Fixed `type_contents_order`: Reorganized to put all nested types (SyncOptions, SyncResult, DetailedSyncResult, TypeSyncResult) before instance properties +- Fixed `line_length`: Changed line 194 to use multi-line string literal +- **Key change**: Type structure now follows: nested types → properties → initializer → methods + +**2. ExportCommand.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to properties in nested structs +- Fixed `type_contents_order`: Moved all nested types (ExportData, RecordExport, ExportError) to top of enum +- Fixed `conditional_returns_on_newline`: Line 85 (now 113) guard statement +- **Key change**: Nested struct properties needed to be `internal` (not `private`) for Codable memberwise initializer + +**3. VirtualBuddyFetcher.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to struct, typealias, initializers, and functions +- Fixed `explicit_top_level_acl`: Added `internal` to VirtualBuddyFetcher and VirtualBuddyFetcherError +- Fixed `line_length`: Split two long print statements (lines 96, 113) +- Fixed `multiline_arguments_brackets`: URLComponents initializer now has closing bracket on new line +- **Key change**: All public-facing types and methods now explicitly `internal` + +**4. BushelCloudKitService.swift** ✅ +- Fixed `type_contents_order`: Moved `service` instance property after `recordTypes` type property +- Fixed `line_length`: Line 207-208 split using multi-line string literal with backslash continuation +- Fixed `multiline_arguments_brackets`: Line 184 executeBatchOperations call now has closing bracket on new line +- **Key change**: Static properties must come before instance properties + +**5. PEMValidator.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to static func validate +- Fixed `explicit_top_level_acl`: Added `internal` to PEMValidator enum +- Fixed `line_length`: Lines 57, 66, 89 all split using multi-line string literals +- **Key change**: All three error suggestion strings converted to multi-line format for readability + +### ✅ Phase 2: Data Source Files (5/5 Complete) ✨ + +#### Completed Files: + +**1. DataSourcePipeline+Deduplication.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to 4 deduplication functions (lines 36, 59, 168, 182) +- **Skipped**: `discouraged_optional_boolean` (7 violations) - intentional tri-state Bool? for signing status +- **Key change**: All deduplication methods now have explicit access control + +**2. XcodeReleasesFetcher.swift** ✅ +- Fixed `type_contents_order`: Moved `init()` after all nested types (was at line 50, now at line 126) +- Fixed `conditional_returns_on_newline`: Line 175 guard statement now has return on new line +- **Key change**: Nested types → init → methods ordering now correct + +**3. MrMacintoshFetcher.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to FetchError enum (line 227) and loggingCategory (line 235) +- Fixed `conditional_returns_on_newline`: Three guard statements (lines 98, 114, 118) now have returns on new lines +- **Key change**: All 5 guard early returns now properly formatted + +**4. SwiftVersionFetcher.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to struct, typealias, fetch method, and FetchError enum (lines 39, 40, 56, 104) +- Fixed `explicit_top_level_acl`: Added `internal` to top-level struct (line 39) +- Fixed `multiline_arguments_brackets`: Line 87 closing bracket moved to new line +- **Key change**: Consistent internal access control throughout + +**5. MESUFetcher.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to FetchError enum (line 117) +- Fixed `line_length`: Line 69 comment split into two lines +- **Key change**: Long plist structure comment now readable within line limit + +### ✅ Phase 3: Configuration Files (3/3 Complete) ✨ + +#### Completed Files: + +**1. ConfigurationLoader.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to 8 functions (lines 74, 79, 87, 98, 119, 143, 155, 167) +- **Functions fixed**: readString, readInt, readDouble, read(_:ConfigKey), read(_:ConfigKey), read(_:OptionalConfigKey), read(_:OptionalConfigKey), read(_:OptionalConfigKey) +- **Key change**: All configuration reader helper methods now have explicit access control + +**2. ConfigurationKeys.swift** ✅ +- Fixed `multiline_arguments_brackets`: Line 101 closing bracket moved to new line +- **Key change**: Multi-line ConfigKey initialization now properly formatted + +**3. CloudKitConfiguration.swift** ✅ +- Fixed `line_length`: Line 110 error message split using multi-line string literal +- **Key change**: Long error message for invalid CLOUDKIT_ENVIRONMENT now readable + +### ✅ Phase 4: CloudKit Extensions (5/5 Complete) ✨ + +#### Completed Files: + +**1. RestoreImageRecord+CloudKit.swift** ✅ +- Fixed `type_contents_order`: Moved `toCloudKitFields()` instance method after static methods +- **Order now**: cloudKitRecordType (type property) → from/formatForDisplay (static methods) → toCloudKitFields (instance method) +- **Key change**: Instance methods now properly placed after type methods + +**2. XcodeVersionRecord+CloudKit.swift** ✅ +- Fixed `type_contents_order`: Moved `toCloudKitFields()` instance method after static methods +- Fixed `multiline_arguments_brackets`: Two FieldValue.Reference calls (lines 62, 70) now have closing brackets on new lines +- **Key change**: Both type order and multiline formatting corrected + +**3. SwiftVersionRecord+CloudKit.swift** ✅ +- Fixed `type_contents_order`: Moved `toCloudKitFields()` instance method after static methods +- **Key change**: Consistent ordering with other CloudKitRecord conformances + +**4. DataSourceMetadata+CloudKit.swift** ✅ +- Fixed `type_contents_order`: Moved `toCloudKitFields()` instance method after static methods +- **Key change**: All CloudKit extension files now follow same pattern + +**5. FieldValue+URL.swift** ✅ +- Fixed `type_contents_order`: Moved `urlValue` property before `init(url:)` initializer +- **Order now**: instance properties → initializers (correct Swift convention) +- **Key change**: Properties must come before initializers + +### ✅ Phase 5: Remaining Source and Test Files (~60/60 Complete) ✨ + +#### Source Files Fixed: + +**1. ConsoleOutput.swift** ✅ +- Fixed `conditional_returns_on_newline`: Line 45 guard statement now has return on new line +- **Key change**: Verbose mode check now properly formatted + +**2. SyncEngine+Export.swift** ✅ +- Fixed `line_length`: Line 86 split using multi-line string literal with backslash continuation +- Fixed `type_contents_order`: Moved `ExportResult` struct before `export()` method (nested types before methods) +- **Key change**: Correct ordering of subtypes → methods in extension + +**3. IPSWFetcher.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to struct, typealias, and fetch method +- Fixed `multiline_arguments_brackets`: URLSession.fetchLastModified call closing bracket moved to new line +- **Key change**: Complete access control coverage + +**4. DataSourcePipeline.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to configuration property and fetchWithMetadata method +- Fixed `type_contents_order`: Reorganized to subtypes → properties → init → methods +- Fixed `conditional_returns_on_newline`: Line 149 guard statement +- Fixed `line_length`: Line 182 print statement split +- **Key change**: Major reorganization for correct type ordering + +**5. DataSourcePipeline+Fetchers.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to fetchRestoreImages, fetchXcodeVersions, fetchSwiftVersions methods +- **Key change**: All fetcher orchestration methods now explicit + +**6. DataSourcePipeline+ReferenceResolution.swift** ✅ +- Fixed `explicit_acl`: Added `internal` to resolveXcodeVersionReferences method +- Fixed `conditional_returns_on_newline`: Line 60 guard statement +- **Key change**: Reference resolution logic properly formatted + +#### AppleDB Model Files Fixed (9 files): + +**7-15. AppleDB Data Source Models** ✅ +- **AppleDBEntry.swift**: Added `internal` to all 9 properties, moved CodingKeys before properties +- **AppleDBHashes.swift**: Added `internal` to 2 properties +- **AppleDBLink.swift**: Added `internal` to 3 properties +- **AppleDBSource.swift**: Added `internal` to 6 properties +- **GitHubCommit.swift**: Added `internal` to 2 properties +- **GitHubCommitsResponse.swift**: Added `internal` to 2 properties +- **GitHubCommitter.swift**: Added `internal` to 1 property +- **SignedStatus.swift**: Added `internal` to 3 functions (init, encode, isSigned) +- **Key change**: All AppleDB Codable models now have explicit access control with correct type ordering + +#### TheAppleWiki Model Files Fixed (5 files): + +**16-20. TheAppleWiki Data Source Models** ✅ +- **IPSWParser.swift**: Added `internal` to 2 properties and 1 function, fixed 1 conditional return, fixed 1 multiline bracket +- **IPSWVersion.swift**: Added `internal` to 10 properties and 2 computed properties, fixed 1 conditional return +- **ParseContent.swift**: Added `internal` to 2 properties +- **ParseResponse.swift**: Added `internal` to 1 property +- **TextContent.swift**: Added `internal` to 1 property, moved CodingKeys before property +- **Key change**: Wikipedia parser models fully compliant with access control rules + +#### Data Source Fetcher Type Ordering Fixed (6 files): + +**21-26. Fetcher Reorganization** ✅ +- **XcodeReleasesFetcher.swift**: Reorganized nested types before properties (8+ type_contents_order fixes) +- **SwiftVersionFetcher.swift**: Reordered nested types → type properties → methods +- **MESUFetcher.swift**: Reordered nested types → initializer → methods +- **AppleDBFetcher.swift**: Fixed multiline bracket + reorganized type/instance properties → type/instance methods +- **MrMacintoshFetcher.swift**: Reordered nested types → methods (5 violations) +- **TheAppleWikiFetcher.swift**: Fixed multiline bracket in fetchLastModified call +- **Key change**: All fetchers now follow consistent structure: nested types → properties → init → methods + +#### Test Files Fixed (38+ files): + +**27-64. Complete Test Suite** ✅ +- **Mocks/** (8 files): MockAppleDBFetcher, MockIPSWFetcher, MockMESUFetcher, MockSwiftVersionFetcher, MockXcodeReleasesFetcher, MockCloudKitService, MockURLProtocol, MockFetcherError + - Added `internal` to all struct/class/enum declarations + - Added `internal` to all properties and methods + - Fixed 2 conditional_returns_on_newline in MockCloudKitService + +- **Utilities/** (3 files): TestFixtures, FieldValue+Assertions, MockRecordInfo + - Added `internal` to all static properties (33 fixtures in TestFixtures) + - Fixed 2 multiline_arguments_brackets + - Fixed 1 type_contents_order (moved url() helper after all static properties) + - Fixed 2 duplicate access modifiers (private internal → private) + +- **Models/** (4 files): RestoreImageRecordTests, XcodeVersionRecordTests, SwiftVersionRecordTests, DataSourceMetadataTests + - Added `internal` to all test structs and methods + +- **CloudKit/** (2 files): MockCloudKitServiceTests, PEMValidatorTests + - Added `internal` to all test structs and methods + +- **Configuration/** (2 files): ConfigurationLoaderTests, FetchConfigurationTests + - Added `internal` to all test structs and methods + - Fixed 1 type_contents_order (moved createLoader helper after nested types) + - Fixed 1 duplicate access modifier + +- **DataSources/** (11 files): All deduplication, merge, and fetcher tests + - Added `internal` to all test structs and methods + - Fixed 2 multiline_arguments_brackets in VirtualBuddyFetcherTests + +- **ErrorHandling/** (4 files): All error handling tests + - Added `internal` to all test structs and methods + +- **Extensions/** (1 file): FieldValueURLTests + - Added `internal` to all test methods + - Fixed 1 line_length (split long URL string) + +- **ConfigKeyKitTests/** (4 files): ConfigKeyTests, ConfigKeySourceTests, NamingStyleTests, OptionalConfigKeyTests + - Added `internal` to all test structs and methods + - Fixed 2 multiline_arguments_brackets in ConfigKeyTests + +**Key change**: All 390+ explicit_acl violations in test files resolved + +#### ConfigKey Framework Fixed (1 file): + +**65. ConfigKey.swift** ✅ +- Fixed `type_contents_order`: Moved `boolDefault` property before initializers (lines 139, 154) +- **Key change**: Properties now correctly appear before initializers in Bool extension + +## Phase 5 Statistics: +- **Total files fixed**: ~65 files +- **explicit_acl violations fixed**: 400+ +- **type_contents_order violations fixed**: 30+ +- **conditional_returns_on_newline violations fixed**: 10+ +- **line_length violations fixed**: 5+ +- **multiline_arguments_brackets violations fixed**: 15+ +- **Build status**: ✅ All builds successful +- **Final verification**: ✅ 0 remaining violations for target rules + +## Access Control Strategy Used + +- **internal** - Default for most declarations (structs, classes, functions, properties) +- **public** - Only for: + - CloudKitRecord protocol conformances (in Extensions/*+CloudKit.swift) + - Public APIs explicitly exported (BushelCloudKitService, SyncEngine, commands) +- **private** - File-scoped utilities and nested helper types + +## Type Contents Order Standard + +Correct order within a type: +1. `type_property` (static let/var) +2. `subtype` (nested types) +3. `instance_property` (let/var) +4. `initializer` (init) +5. `type_method` (static func) +6. `other_method` (instance func) + +## Common Patterns Used + +### 1. Line Length Fixes +```swift +// Before (too long) +print("Very long message with \(interpolation) and more \(stuff)") + +// After (split with string concatenation) +print( + "Very long message with \(interpolation) " + + "and more \(stuff)" +) + +// OR use multi-line string literal for logging +logger.debug( + """ + Very long message with \(interpolation) \ + and more \(stuff) + """ +) +``` + +### 2. Conditional Returns on Newline +```swift +// Before +guard condition else { return } + +// After +guard condition else { + return +} +``` + +### 3. Multiline Arguments Brackets +```swift +// Before +let obj = SomeType( + arg1: value1, + arg2: value2) + +// After +let obj = SomeType( + arg1: value1, + arg2: value2 +) +``` + +## Important Notes + +1. **Codable Structs**: When adding access control to Codable struct properties, they must be `internal` (not `private`) for the memberwise initializer to work. + +2. **OSLogMessage**: Cannot use string concatenation (+) with Logger.debug(). Must use multi-line string literals with `\` continuation instead. + +3. **Discouraged Optional Boolean**: We're skipping all 13 violations in `DataSourcePipeline+Deduplication.swift` as the tri-state Bool? is intentional for signing status (true/false/unknown). + +4. **Build Status**: After each file fix, run `swift build` to verify. All changes so far compile successfully. + +## Testing Strategy + +After each phase: +1. Run `LINT_MODE=STRICT ./Scripts/lint.sh` to verify fixes +2. Run `swift build` to ensure no compilation errors +3. Run `swift test` to ensure tests still pass + +## Estimated Remaining Work + +- **Phase 1**: 2 files (~30 minutes) +- **Phase 2**: 5 files (~45 minutes) +- **Phase 3**: 3 files (~20 minutes) +- **Phase 4**: 5 files (~30 minutes) +- **Phase 5**: ~60 test files (~90 minutes using bulk pattern matching) + +**Total remaining**: ~3-4 hours of focused work + +## Final Violation Count + +- **Before**: ~900 total violations +- **After ALL Phases**: ~300 violations (all out-of-scope items) +- **Target violations FIXED**: + - `explicit_acl`: ~450 violations fixed + - `type_contents_order`: ~40 violations fixed + - `conditional_returns_on_newline`: ~15 violations fixed + - `line_length`: ~10 violations fixed + - `multiline_arguments_brackets`: ~20 violations fixed + - `explicit_top_level_acl`: ~5 violations fixed +- **Total fixed**: ~540 violations across 83 files + +## All Files Modified (83 total) + +### Phase 1: High-Impact Files (5 files) +1. SyncEngine.swift +2. ExportCommand.swift +3. VirtualBuddyFetcher.swift +4. BushelCloudKitService.swift +5. PEMValidator.swift + +### Phase 2: Data Source Files (5 files) +6. DataSourcePipeline+Deduplication.swift +7. XcodeReleasesFetcher.swift +8. MrMacintoshFetcher.swift +9. SwiftVersionFetcher.swift +10. MESUFetcher.swift + +### Phase 3: Configuration Files (3 files) +11. ConfigurationLoader.swift +12. ConfigurationKeys.swift +13. CloudKitConfiguration.swift + +### Phase 4: CloudKit Extensions (5 files) +14. RestoreImageRecord+CloudKit.swift +15. XcodeVersionRecord+CloudKit.swift +16. SwiftVersionRecord+CloudKit.swift +17. DataSourceMetadata+CloudKit.swift +18. FieldValue+URL.swift + +### Phase 5: Remaining Source and Test Files (65 files) +**Source Files (21):** +19. ConsoleOutput.swift +20. SyncEngine+Export.swift +21. IPSWFetcher.swift +22. DataSourcePipeline.swift +23. DataSourcePipeline+Fetchers.swift +24. DataSourcePipeline+ReferenceResolution.swift +25-32. AppleDB Models (8 files) +33-37. TheAppleWiki Models (5 files) +38-43. Fetchers (6 files: Xcode, Swift, MESU, AppleDB, MrMacintosh, TheAppleWiki) +44. ConfigKey.swift + +**Test Files (38+ files):** +45-52. Mocks (8 files) +53-55. Utilities (3 files) +56-59. Models Tests (4 files) +60-61. CloudKit Tests (2 files) +62-63. Configuration Tests (2 files) +64-74. DataSources Tests (11 files) +75-78. ErrorHandling Tests (4 files) +79. Extensions Tests (1 file) +80-83. ConfigKeyKit Tests (4 files) + +## ✨ ALL PHASES COMPLETE! ✨ + +1. ✅ **Phase 1 Complete!** All 5 high-impact files have been fixed. + +2. ✅ **Phase 2 Complete!** All 5 data source files have been fixed. + +3. ✅ **Phase 3 Complete!** All 3 configuration files have been fixed. + +4. ✅ **Phase 4 Complete!** All 5 CloudKit extension files have been fixed. + +5. ✅ **Phase 5 Complete!** All ~65 remaining source and test files have been fixed. + +## Out of Scope (Not Being Fixed) + +- `file_length` violations (requires splitting files) +- `file_types_order` violations (4 total, per user decision) +- `function_body_length` violations (requires refactoring logic) +- `cyclomatic_complexity` violations (requires simplifying logic) +- `discouraged_optional_boolean` (intentional design) +- `force_unwrapping` violations (requires architectural changes) +- `missing_docs` violations (requires writing documentation) + +## Reference Documentation + +- Original plan: `/Users/leo/.claude/plans/expressive-cuddling-walrus.md` +- SwiftLint rules: https://realm.github.io/SwiftLint/ +- Type contents order: Nested types → Properties → Initializers → Methods diff --git a/Examples/BushelCloud/Makefile b/Examples/BushelCloud/Makefile new file mode 100644 index 00000000..965c4a85 --- /dev/null +++ b/Examples/BushelCloud/Makefile @@ -0,0 +1,69 @@ +.PHONY: build test lint format clean install docker-build docker-run docker-test xcode help + +# Default target +.DEFAULT_GOAL := help + +# Variables +EXECUTABLE_NAME = bushel-cloud +INSTALL_PATH = /usr/local/bin +BUILD_PATH = .build/release/$(EXECUTABLE_NAME) +DOCKER_IMAGE = swift:6.2-noble + +## build: Build the project in release mode +build: + @echo "Building BushelCloud..." + swift build -c release + +## test: Run unit tests +test: + @echo "Running tests..." + swift test + +## lint: Run linting checks (SwiftLint, swift-format, periphery) +lint: + @echo "Running linting..." + ./Scripts/lint.sh + +## format: Format code with swift-format +format: + @echo "Formatting code..." + mint run swift-format format --recursive --parallel --in-place Sources Tests + +## clean: Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + swift package clean + rm -rf .build + +## install: Install executable to /usr/local/bin +install: build + @echo "Installing $(EXECUTABLE_NAME) to $(INSTALL_PATH)..." + @install -m 755 $(BUILD_PATH) $(INSTALL_PATH)/$(EXECUTABLE_NAME) + @echo "Installed successfully!" + +## docker-build: Build project in Docker container +docker-build: + @echo "Building in Docker ($(DOCKER_IMAGE))..." + docker run --rm -v $(PWD):/workspace -w /workspace $(DOCKER_IMAGE) swift build + +## docker-test: Run tests in Docker container +docker-test: + @echo "Running tests in Docker ($(DOCKER_IMAGE))..." + docker run --rm -v $(PWD):/workspace -w /workspace $(DOCKER_IMAGE) swift test + +## docker-run: Run interactive shell in Docker container +docker-run: + @echo "Starting Docker shell ($(DOCKER_IMAGE))..." + docker run -it --rm -v $(PWD):/workspace -w /workspace $(DOCKER_IMAGE) bash + +## xcode: Generate Xcode project using XcodeGen +xcode: + @echo "Generating Xcode project..." + @mint run xcodegen generate + @echo "Xcode project generated! Open BushelCloud.xcodeproj" + +## help: Show this help message +help: + @echo "BushelCloud Makefile targets:" + @echo "" + @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' diff --git a/Examples/BushelCloud/Mintfile b/Examples/BushelCloud/Mintfile new file mode 100644 index 00000000..7c931c11 --- /dev/null +++ b/Examples/BushelCloud/Mintfile @@ -0,0 +1,3 @@ +swiftlang/swift-format@602.0.0 +realm/SwiftLint@0.62.2 +peripheryapp/periphery@3.2.0 diff --git a/Examples/BushelCloud/Package.resolved b/Examples/BushelCloud/Package.resolved new file mode 100644 index 00000000..49126264 --- /dev/null +++ b/Examples/BushelCloud/Package.resolved @@ -0,0 +1,195 @@ +{ + "originHash" : "c3ac1cf77d89f143a19ef295fe93dc532ed8453816f62104a1d89923205611da", + "pins" : [ + { + "identity" : "bushelkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/BushelKit.git", + "state" : { + "revision" : "1b3e8d82915743574ac9d2aa882d64bf56822a72", + "version" : "3.0.0-alpha.3" + } + }, + { + "identity" : "felinepine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/FelinePine.git", + "state" : { + "revision" : "7abf84e0cded44bc99fae106030e8e25e270dae0", + "version" : "1.0.0" + } + }, + { + "identity" : "felinepineswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/FelinePineSwift.git", + "state" : { + "revision" : "e0a414b8ee7ba1290e9d3b32f4c6cceff95af508", + "version" : "1.0.0" + } + }, + { + "identity" : "ipswdownloads", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/IPSWDownloads.git", + "state" : { + "revision" : "2e8ad36b5f74285dbe104e7ae99f8be0cd06b7b8", + "version" : "1.0.2" + } + }, + { + "identity" : "lrucache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nicklockwood/LRUCache.git", + "state" : { + "revision" : "0d91406ecd4d6c1c56275866f00508d9aeacc92a", + "version" : "1.2.0" + } + }, + { + "identity" : "osver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/OSVer", + "state" : { + "revision" : "448f170babc2f6c9897194a4b42719994639325d", + "version" : "1.0.0" + } + }, + { + "identity" : "radiantkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/RadiantKit.git", + "state" : { + "revision" : "a65d6721b6b396f0503a3876ddab3f2399b21d4e", + "version" : "1.0.0-beta.5" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "d86f244ed497d48012782e2f59c985a55e77b3f5", + "version" : "2.11.3" + } + } + ], + "version" : 3 +} diff --git a/Examples/BushelCloud/Package.swift b/Examples/BushelCloud/Package.swift new file mode 100644 index 00000000..00cfd538 --- /dev/null +++ b/Examples/BushelCloud/Package.swift @@ -0,0 +1,149 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +// swiftlint:disable explicit_acl explicit_top_level_acl + +import PackageDescription + +// MARK: - Swift Settings Configuration + +let swiftSettings: [SwiftSetting] = [ + // Swift 6.2 Upcoming Features (not yet enabled by default) + // SE-0335: Introduce existential `any` + .enableUpcomingFeature("ExistentialAny"), + // SE-0409: Access-level modifiers on import declarations + .enableUpcomingFeature("InternalImportsByDefault"), + // SE-0444: Member import visibility (Swift 6.1+) + .enableUpcomingFeature("MemberImportVisibility"), + // SE-0413: Typed throws + .enableUpcomingFeature("FullTypedThrows"), + + // Experimental Features (stable enough for use) + // SE-0426: BitwiseCopyable protocol + .enableExperimentalFeature("BitwiseCopyable"), + // SE-0432: Borrowing and consuming pattern matching for noncopyable types + .enableExperimentalFeature("BorrowingSwitch"), + // Extension macros + .enableExperimentalFeature("ExtensionMacros"), + // Freestanding expression macros + .enableExperimentalFeature("FreestandingExpressionMacros"), + // Init accessors + .enableExperimentalFeature("InitAccessors"), + // Isolated any types + .enableExperimentalFeature("IsolatedAny"), + // Move-only classes + .enableExperimentalFeature("MoveOnlyClasses"), + // Move-only enum deinits + .enableExperimentalFeature("MoveOnlyEnumDeinits"), + // SE-0429: Partial consumption of noncopyable values + .enableExperimentalFeature("MoveOnlyPartialConsumption"), + // Move-only resilient types + .enableExperimentalFeature("MoveOnlyResilientTypes"), + // Move-only tuples + .enableExperimentalFeature("MoveOnlyTuples"), + // SE-0427: Noncopyable generics + .enableExperimentalFeature("NoncopyableGenerics"), + // One-way closure parameters + // .enableExperimentalFeature("OneWayClosureParameters"), + // Raw layout types + .enableExperimentalFeature("RawLayout"), + // Reference bindings + .enableExperimentalFeature("ReferenceBindings"), + // SE-0430: sending parameter and result values + .enableExperimentalFeature("SendingArgsAndResults"), + // Symbol linkage markers + .enableExperimentalFeature("SymbolLinkageMarkers"), + // Transferring args and results + .enableExperimentalFeature("TransferringArgsAndResults"), + // SE-0393: Value and Type Parameter Packs + .enableExperimentalFeature("VariadicGenerics"), + // Warn unsafe reflection + .enableExperimentalFeature("WarnUnsafeReflection"), + + // Enhanced compiler checking + // .unsafeFlags([ + // // Enable concurrency warnings + // "-warn-concurrency", + // // Enable actor data race checks + // "-enable-actor-data-race-checks", + // // Complete strict concurrency checking + // "-strict-concurrency=complete", + // // Enable testing support + // "-enable-testing", + // // Warn about functions with >100 lines + // "-Xfrontend", "-warn-long-function-bodies=100", + // // Warn about slow type checking expressions + // "-Xfrontend", "-warn-long-expression-type-checking=100" + // ]) +] + +let package = Package( + name: "BushelCloud", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .watchOS(.v11), + .tvOS(.v18), + .visionOS(.v2) + ], + products: [ + .library(name: "ConfigKeyKit", targets: ["ConfigKeyKit"]), + .library(name: "BushelCloudKit", targets: ["BushelCloudKit"]), + .executable(name: "bushel-cloud", targets: ["BushelCloudCLI"]) + ], + dependencies: [ + .package(name: "MistKit", path: "../.."), + .package(url: "https://github.com/brightdigit/BushelKit.git", from: "3.0.0-alpha.2"), + .package(url: "https://github.com/brightdigit/IPSWDownloads.git", from: "1.0.0"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), + .package( + url: "https://github.com/apple/swift-configuration.git", + from: "1.0.0", + traits: ["CommandLineArguments"] + ) + ], + targets: [ + .target( + name: "ConfigKeyKit", + dependencies: [], + swiftSettings: swiftSettings + ), + .target( + name: "BushelCloudKit", + dependencies: [ + .target(name: "ConfigKeyKit"), + .product(name: "MistKit", package: "MistKit"), + .product(name: "BushelLogging", package: "BushelKit"), + .product(name: "BushelFoundation", package: "BushelKit"), + .product(name: "BushelUtilities", package: "BushelKit"), + .product(name: "BushelVirtualBuddy", package: "BushelKit"), + .product(name: "IPSWDownloads", package: "IPSWDownloads"), + .product(name: "SwiftSoup", package: "SwiftSoup"), + .product(name: "Configuration", package: "swift-configuration") + ], + swiftSettings: swiftSettings + ), + .executableTarget( + name: "BushelCloudCLI", + dependencies: [ + .target(name: "BushelCloudKit") + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "ConfigKeyKitTests", + dependencies: [ + .target(name: "ConfigKeyKit") + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "BushelCloudKitTests", + dependencies: [ + .target(name: "BushelCloudKit") + ], + swiftSettings: swiftSettings + ) + ] +) +// swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Examples/Bushel/README.md b/Examples/BushelCloud/README.md similarity index 69% rename from Examples/Bushel/README.md rename to Examples/BushelCloud/README.md index d416e66d..f74476d8 100644 --- a/Examples/Bushel/README.md +++ b/Examples/BushelCloud/README.md @@ -1,5 +1,13 @@ # Bushel Demo - CloudKit Data Synchronization +[![CI](https://github.com/brightdigit/BushelCloud/actions/workflows/BushelCloud.yml/badge.svg)](https://github.com/brightdigit/BushelCloud/actions/workflows/BushelCloud.yml) +[![CodeQL](https://github.com/brightdigit/BushelCloud/actions/workflows/codeql.yml/badge.svg)](https://github.com/brightdigit/BushelCloud/actions/workflows/codeql.yml) +[![codecov](https://codecov.io/gh/brightdigit/BushelCloud/branch/main/graph/badge.svg)](https://codecov.io/gh/brightdigit/BushelCloud) +[![SwiftLint](https://img.shields.io/badge/SwiftLint-passing-success.svg)](https://github.com/realm/SwiftLint) +[![Swift 6.2+](https://img.shields.io/badge/Swift-6.2%2B-orange.svg)](https://swift.org) +[![Platforms](https://img.shields.io/badge/Platforms-macOS%20%7C%20Linux%20%7C%20Windows-blue.svg)](https://swift.org) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + A command-line tool demonstrating MistKit's CloudKit Web Services capabilities by syncing macOS restore images, Xcode versions, and Swift compiler versions to CloudKit. > 📖 **Tutorial-Friendly Demo** - This example is designed for developers learning CloudKit and MistKit. Use the `--verbose` flag to see detailed explanations of CloudKit operations and MistKit usage patterns. @@ -67,7 +75,7 @@ The demo integrates with multiple data sources to gather comprehensive version i ### Components ```text -BushelImages/ +BushelCloud/ ├── DataSources/ # Data fetchers for external APIs │ ├── IPSWFetcher.swift │ ├── XcodeReleasesFetcher.swift @@ -84,11 +92,30 @@ BushelImages/ │ ├── RecordBuilder.swift │ └── SyncEngine.swift └── Commands/ # CLI commands - ├── BushelImagesCLI.swift + ├── BushelCloudCLI.swift ├── SyncCommand.swift └── ExportCommand.swift ``` +### BushelKit Integration + +BushelCloud uses [BushelKit](https://github.com/brightdigit/BushelKit) as its modular foundation, providing: + +**Core Modules:** +- **BushelFoundation** - Domain models (RestoreImageRecord, XcodeVersionRecord, SwiftVersionRecord) +- **BushelUtilities** - Formatting helpers, JSON decoding, console output +- **BushelLogging** - Unified logging abstractions + +**Current Integration:** +- Git subrepo at `Packages/BushelKit/` for rapid development +- Local path dependency during migration phase + +**Future:** +- After BushelKit v2.0 stable release → versioned remote dependency +- BushelKit will support VM management features + +**Documentation:** [BushelKit Docs](https://docs.getbushel.app/docc) + ## Features Demonstrated ### MistKit Capabilities @@ -140,7 +167,7 @@ BushelImages/ 2. **Server-to-Server Key** - Generate from CloudKit Dashboard → API Access 3. **Private Key File** - Download the `.pem` file when creating the key -See [CLOUDKIT-SETUP.md](./CLOUDKIT-SETUP.md) for detailed setup instructions. +For detailed setup instructions, run `swift package generate-documentation` and view the CloudKit Setup guide in the generated documentation. ### Building @@ -149,7 +176,7 @@ See [CLOUDKIT-SETUP.md](./CLOUDKIT-SETUP.md) for detailed setup instructions. swift build # Run the demo -.build/debug/bushel-images --help +.build/debug/bushel-cloud --help ``` ### First Sync (Learning Mode) @@ -158,13 +185,16 @@ Run with `--verbose` to see educational explanations of what's happening: ```bash export CLOUDKIT_KEY_ID="YOUR_KEY_ID" -export CLOUDKIT_KEY_FILE="./path/to/private-key.pem" +export CLOUDKIT_PRIVATE_KEY_PATH="./path/to/private-key.pem" + +# Optional: Enable VirtualBuddy TSS signing status +export VIRTUALBUDDY_API_KEY="YOUR_VIRTUALBUDDY_API_KEY" # Sync with verbose logging to learn how MistKit works -.build/debug/bushel-images sync --verbose +.build/debug/bushel-cloud sync --verbose # Or do a dry run first to see what would be synced -.build/debug/bushel-images sync --dry-run --verbose +.build/debug/bushel-cloud sync --dry-run --verbose ``` **What the verbose flag shows:** @@ -174,6 +204,50 @@ export CLOUDKIT_KEY_FILE="./path/to/private-key.pem" - ⚙️ Record dependency ordering - 🌐 Actual CloudKit API calls and responses +## Installation + +### Method 1: Build from Source (Recommended) + +Clone and build the project: + +```bash +git clone https://github.com/brightdigit/BushelCloud.git +cd BushelCloud +swift build -c release +.build/release/bushel-cloud --help +``` + +### Method 2: Install to System Path + +Build and install to `/usr/local/bin`: + +```bash +git clone https://github.com/brightdigit/BushelCloud.git +cd BushelCloud +make install +``` + +This makes `bushel-cloud` available globally. + +### Method 3: Docker + +Run without local Swift installation: + +```bash +git clone https://github.com/brightdigit/BushelCloud.git +cd BushelCloud +make docker-run +``` + +### Prerequisites for All Methods + +Before running any sync operations, you'll need: +1. CloudKit container (create in [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/)) +2. Server-to-Server Key (generate from API Access section) +3. Private key `.pem` file (downloaded when creating key) + +See [Authentication Setup](#authentication-setup) for detailed instructions. + ## Usage ### Sync Command @@ -182,27 +256,27 @@ Fetch data from all sources and upload to CloudKit: ```bash # Basic usage -bushel-images sync \ +bushel-cloud sync \ --container-id "iCloud.com.brightdigit.Bushel" \ --key-id "YOUR_KEY_ID" \ --key-file ./path/to/private-key.pem # With verbose logging (recommended for learning) -bushel-images sync --verbose +bushel-cloud sync --verbose # Dry run (fetch data but don't upload to CloudKit) -bushel-images sync --dry-run +bushel-cloud sync --dry-run # Selective sync -bushel-images sync --restore-images-only -bushel-images sync --xcode-only -bushel-images sync --swift-only -bushel-images sync --no-betas # Exclude beta/RC releases +bushel-cloud sync --restore-images-only +bushel-cloud sync --xcode-only +bushel-cloud sync --swift-only +bushel-cloud sync --no-betas # Exclude beta/RC releases # Use environment variables (recommended) export CLOUDKIT_KEY_ID="YOUR_KEY_ID" -export CLOUDKIT_KEY_FILE="./path/to/private-key.pem" -bushel-images sync --verbose +export CLOUDKIT_PRIVATE_KEY_PATH="./path/to/private-key.pem" +bushel-cloud sync --verbose ``` ### Export Command @@ -211,37 +285,33 @@ Query and export CloudKit data to JSON file: ```bash # Export to file -bushel-images export \ +bushel-cloud export \ --container-id "iCloud.com.brightdigit.Bushel" \ --key-id "YOUR_KEY_ID" \ --key-file ./path/to/private-key.pem \ --output ./bushel-data.json # With verbose logging -bushel-images export --verbose --output ./bushel-data.json +bushel-cloud export --verbose --output ./bushel-data.json # Pretty-print JSON -bushel-images export --pretty --output ./bushel-data.json +bushel-cloud export --pretty --output ./bushel-data.json # Export to stdout for piping -bushel-images export --pretty | jq '.restoreImages | length' +bushel-cloud export --pretty | jq '.restoreImages | length' ``` ### Help ```bash -bushel-images --help -bushel-images sync --help -bushel-images export --help +bushel-cloud --help +bushel-cloud sync --help +bushel-cloud export --help ``` ### Xcode Setup -See [XCODE_SCHEME_SETUP.md](./XCODE_SCHEME_SETUP.md) for detailed instructions on: -- Configuring the Xcode scheme -- Setting environment variables -- Getting CloudKit credentials -- Debugging tips +For Xcode setup and debugging instructions, see the "Xcode Development Setup" section in CLAUDE.md. ## CloudKit Schema @@ -370,13 +440,13 @@ cd Examples/Bushel ./Scripts/setup-cloudkit-schema.sh ``` -See [CLOUDKIT_SCHEMA_SETUP.md](./CLOUDKIT_SCHEMA_SETUP.md) for detailed instructions. +Run the automated setup script: `./Scripts/setup-cloudkit-schema.sh` or view the CloudKit Setup guide in the documentation. ### Option 2: Manual Setup Create the record types manually in [CloudKit Dashboard](https://icloud.developer.apple.com/). -See [XCODE_SCHEME_SETUP.md](./XCODE_SCHEME_SETUP.md#cloudkit-schema-setup) for field definitions. +See the "CloudKit Schema Field Reference" section in CLAUDE.md for complete field definitions. ## Authentication Setup @@ -406,7 +476,7 @@ After setting up your CloudKit schema, you need to create a Server-to-Server Key **Method 1: Command-line flags** ```bash -bushel-images sync \ +bushel-cloud sync \ --key-id "YOUR_KEY_ID" \ --key-file ~/.cloudkit/bushel-private-key.pem ``` @@ -415,10 +485,13 @@ bushel-images sync \ ```bash # Add to your ~/.zshrc or ~/.bashrc export CLOUDKIT_KEY_ID="YOUR_KEY_ID" -export CLOUDKIT_KEY_FILE="$HOME/.cloudkit/bushel-private-key.pem" +export CLOUDKIT_PRIVATE_KEY_PATH="$HOME/.cloudkit/bushel-private-key.pem" + +# Optional: VirtualBuddy TSS signing status (get from https://tss.virtualbuddy.app/) +export VIRTUALBUDDY_API_KEY="YOUR_VIRTUALBUDDY_API_KEY" # Then simply run -bushel-images sync +bushel-cloud sync ``` ## Dependencies @@ -428,6 +501,130 @@ bushel-images sync - **SwiftSoup** - HTML parsing for web scraping - **ArgumentParser** - CLI argument parsing +## Development + +### Prerequisites + +- Swift 6.1 or later +- macOS 14.0+ (for full CloudKit functionality) +- Mint (for linting tools): `brew install mint` + +### Dev Containers + +Develop with Linux and test multiple Swift versions using VS Code Dev Containers: + +**Available configurations:** +- Swift 6.1 (Ubuntu Jammy) +- Swift 6.2 (Ubuntu Jammy) +- Swift 6.2 (Ubuntu Noble) - Default + +**Usage:** +1. Install [VS Code Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +2. Open project in VS Code +3. Click "Reopen in Container" or use Command Palette: `Dev Containers: Reopen in Container` +4. Select desired Swift version when prompted + +**Or use directly with Docker:** +```bash +# Swift 6.2 on Ubuntu Noble +docker run -it -v $PWD:/workspace -w /workspace swift:6.2-noble bash + +# Run tests +docker run -v $PWD:/workspace -w /workspace swift:6.2-noble swift test +``` + +### Quick Start with Make + +```bash +make build # Build the project +make test # Run tests +make lint # Run linting +make format # Format code +make xcode # Generate Xcode project +make install # Install to /usr/local/bin +make help # Show all targets +``` + +### Building + +```bash +swift build +# Or with make: +make build +``` + +### Testing + +```bash +swift test +# Or with make: +make test +``` + +### Linting + +```bash +./Scripts/lint.sh +# Or with make: +make lint +``` + +This will: +- Format code with swift-format +- Check style with SwiftLint +- Verify code compiles +- Add copyright headers + +### Docker Commands + +```bash +make docker-build # Build in Docker +make docker-test # Test in Docker +make docker-run # Interactive shell +``` + +### Xcode Project Generation + +Generate Xcode project using XcodeGen: + +```bash +make xcode +# Or directly: +mint run xcodegen generate +``` + +This creates `BushelCloud.xcodeproj` from `project.yml`. The project file is gitignored and regenerated as needed. + +**Targets included:** +- BushelCloud - Main executable +- BushelCloudTests - Unit tests +- Linting - Aggregate target that runs SwiftLint + +### CI/CD + +This project uses GitHub Actions for continuous integration: + +- **Multi-platform builds**: Ubuntu (Noble, Jammy), Windows (2022, 2025), macOS 15 +- **Swift versions**: 6.1, 6.2, 6.2-nightly +- **Xcode versions**: 16.3, 16.4, 26.0 +- **Linting**: SwiftLint, swift-format, periphery +- **Security**: CodeQL static analysis +- **Coverage**: Codecov integration +- **AI Review**: Claude Code for automated PR reviews + +See `.github/workflows/` for workflow configurations. + +### Code Quality Tools + +**Managed via Mint (see `Mintfile`):** +- `swift-format@602.0.0` - Code formatting +- `SwiftLint@0.62.2` - Style and convention linting +- `periphery@3.2.0` - Unused code detection + +**Configuration files:** +- `.swiftlint.yml` - 90+ opt-in rules, strict mode +- `.swift-format` - 2-space indentation, 100-char lines + ## Data Sources Bushel fetches data from multiple external sources including: @@ -438,6 +635,7 @@ Bushel fetches data from multiple external sources including: - **swift.org** - Swift compiler versions - **Apple MESU** - Official restore image metadata - **Mr. Macintosh** - Community-maintained release archive +- **VirtualBuddy TSS API** (optional) - Real-time TSS signing status verification (requires API key from [tss.virtualbuddy.app](https://tss.virtualbuddy.app/)) The `sync` command fetches from all sources, deduplicates records, and uploads to CloudKit. @@ -470,8 +668,8 @@ The `export` command queries existing records from your CloudKit database and ex **❌ "Private key file not found"** ```bash ✅ Solution: Check that your .pem file path is correct -export CLOUDKIT_KEY_FILE="$HOME/.cloudkit/bushel-private-key.pem" -ls -la "$CLOUDKIT_KEY_FILE" # Verify file exists +export CLOUDKIT_PRIVATE_KEY_PATH="$HOME/.cloudkit/bushel-private-key.pem" +ls -la "$CLOUDKIT_PRIVATE_KEY_PATH" # Verify file exists ``` **❌ "Authentication failed" or "Invalid signature"** @@ -500,7 +698,7 @@ See "Limitations" section for details on incremental sync **❌ "Operation failed" with no details** ```bash ✅ Solution: Use --verbose flag to see CloudKit error details -bushel-images sync --verbose +bushel-cloud sync --verbose # Look for serverErrorCode and reason in output ``` @@ -524,7 +722,7 @@ bushel-images sync --verbose ### For Beginners **Start Here:** -1. Run `bushel-images sync --dry-run --verbose` to see what happens without uploading +1. Run `bushel-cloud sync --dry-run --verbose` to see what happens without uploading 2. Review the code in `SyncEngine.swift` to understand the flow 3. Check `BushelCloudKitService.swift` for MistKit usage patterns 4. Explore `RecordBuilder.swift` to see CloudKit record construction @@ -587,7 +785,7 @@ Same as MistKit - MIT License. See main repository LICENSE file. ## Questions? For issues specific to this demo: -- Check [XCODE_SCHEME_SETUP.md](./XCODE_SCHEME_SETUP.md) for configuration help +- Check the "Xcode Development Setup" section in CLAUDE.md for configuration help - Review CloudKit Dashboard for schema and authentication issues For MistKit issues: diff --git a/Examples/BushelCloud/Scripts/bootstrap.sh b/Examples/BushelCloud/Scripts/bootstrap.sh new file mode 100755 index 00000000..3b0a5da7 --- /dev/null +++ b/Examples/BushelCloud/Scripts/bootstrap.sh @@ -0,0 +1,181 @@ +#!/bin/bash + +# BushelCloud Bootstrap Script +# This script sets up the development environment for BushelCloud + +set -eo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo "========================================" +echo "BushelCloud Development Setup" +echo "========================================" +echo "" + +# Check Swift version +echo "Checking Swift version..." +if ! command -v swift &> /dev/null; then + echo -e "${RED}ERROR: Swift is not installed.${NC}" + echo "Please install Swift 6.2 or later from https://swift.org" + exit 1 +fi + +SWIFT_VERSION=$(swift --version | head -n 1) +echo -e "${GREEN}✓${NC} Swift is installed: $SWIFT_VERSION" + +# Check for minimum Swift 6.0 +if ! swift --version | grep -qE "Swift version (6\.[0-9]+|[7-9]\.|[1-9][0-9]+\.)"; then + echo -e "${YELLOW}WARNING: This project requires Swift 6.0 or later.${NC}" + echo "You may encounter compatibility issues with your current Swift version." +fi + +echo "" + +# Check if Mint is installed +echo "Checking for Mint (Swift package manager for executables)..." +if ! command -v mint &> /dev/null; then + echo -e "${YELLOW}Mint is not installed.${NC}" + echo "" + read -p "Would you like to install Mint? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Installing Mint..." + if command -v brew &> /dev/null; then + brew install mint + echo -e "${GREEN}✓${NC} Mint installed via Homebrew" + else + echo -e "${YELLOW}Homebrew not found. Installing Mint from source...${NC}" + git clone https://github.com/yonaskolb/Mint.git /tmp/Mint + cd /tmp/Mint + swift run mint install yonaskolb/mint + cd - + rm -rf /tmp/Mint + echo -e "${GREEN}✓${NC} Mint installed from source" + fi + else + echo -e "${YELLOW}Skipping Mint installation. Some tools may not be available.${NC}" + fi +else + echo -e "${GREEN}✓${NC} Mint is installed" +fi + +echo "" + +# Install development tools via Mint +if command -v mint &> /dev/null && [ -f "Mintfile" ]; then + echo "Installing development tools from Mintfile..." + echo "This may take a few minutes on first run..." + echo "" + + if mint bootstrap; then + echo -e "${GREEN}✓${NC} Development tools installed" + echo " - SwiftLint (code linting)" + echo " - swift-format (code formatting)" + echo " - periphery (unused code detection)" + else + echo -e "${YELLOW}WARNING: Failed to install some development tools.${NC}" + echo "You can install them manually later with: mint bootstrap" + fi +else + echo -e "${YELLOW}Skipping development tools installation (Mint not available or Mintfile not found)${NC}" +fi + +echo "" + +# Check for XcodeGen +echo "Checking for XcodeGen..." +if command -v xcodegen &> /dev/null; then + echo -e "${GREEN}✓${NC} XcodeGen is installed" + + # Generate Xcode project if project.yml exists + if [ -f "project.yml" ]; then + echo "" + read -p "Generate Xcode project? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Generating Xcode project..." + if xcodegen generate; then + echo -e "${GREEN}✓${NC} Xcode project generated" + echo " You can now open BushelCloud.xcodeproj" + else + echo -e "${RED}ERROR: Failed to generate Xcode project${NC}" + fi + fi + fi +else + echo -e "${YELLOW}XcodeGen is not installed.${NC}" + echo "XcodeGen is optional but recommended for Xcode development." + echo "" + read -p "Would you like to install XcodeGen via Homebrew? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + if command -v brew &> /dev/null; then + brew install xcodegen + echo -e "${GREEN}✓${NC} XcodeGen installed" + + # Generate project after installation + if [ -f "project.yml" ]; then + echo "Generating Xcode project..." + xcodegen generate + echo -e "${GREEN}✓${NC} Xcode project generated" + fi + else + echo -e "${YELLOW}Homebrew not found. Please install XcodeGen manually:${NC}" + echo " https://github.com/yonaskolb/XcodeGen" + fi + fi +fi + +echo "" + +# Build the project +echo "Building the project..." +if swift build; then + echo -e "${GREEN}✓${NC} Project built successfully" + echo "" + echo "Executable location: .build/debug/bushel-cloud" +else + echo -e "${RED}ERROR: Build failed${NC}" + echo "Please check the error messages above and resolve any issues." + exit 1 +fi + +echo "" + +# Run tests +echo "Running tests..." +if swift test; then + echo -e "${GREEN}✓${NC} All tests passed" +else + echo -e "${YELLOW}WARNING: Some tests failed${NC}" + echo "You can continue development, but please fix failing tests before committing." +fi + +echo "" +echo "========================================" +echo -e "${GREEN}✓✓✓ Bootstrap complete! ✓✓✓${NC}" +echo "========================================" +echo "" +echo "Next steps:" +echo "" +echo " 1. Set up CloudKit credentials (see .env.example):" +echo " cp .env.example .env" +echo " # Edit .env with your CloudKit credentials" +echo "" +echo " 2. Import CloudKit schema:" +echo " ./Scripts/setup-cloudkit-schema.sh" +echo "" +echo " 3. Run the CLI tool:" +echo " .build/debug/bushel-cloud --help" +echo " .build/debug/bushel-cloud sync --verbose" +echo "" +echo " 4. For Xcode development:" +echo " open BushelCloud.xcodeproj" +echo "" +echo "Documentation: See README.md and CLAUDE.md for detailed guides" +echo "" diff --git a/Examples/BushelCloud/Scripts/header.sh b/Examples/BushelCloud/Scripts/header.sh new file mode 100755 index 00000000..2242c437 --- /dev/null +++ b/Examples/BushelCloud/Scripts/header.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template using a heredoc +read -r -d '' header_template <<'EOF' +// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +EOF + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files in the Generated directory + if [[ "$file" == *"/Generated/"* ]]; then + echo "Skipping $file (generated file)" + continue + fi + + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + # Escape % characters in user-provided values to prevent format specifier injection + filename=$(basename "$file" | sed 's/%/%%/g') + package_safe=$(printf '%s' "$package" | sed 's/%/%%/g') + creator_safe=$(printf '%s' "$creator" | sed 's/%/%%/g') + year_safe=$(printf '%s' "$year" | sed 's/%/%%/g') + company_safe=$(printf '%s' "$company" | sed 's/%/%%/g') + + header=$(printf "$header_template" "$filename" "$package_safe" "$creator_safe" "$year_safe" "$company_safe") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Examples/BushelCloud/Scripts/lint.sh b/Examples/BushelCloud/Scripts/lint.sh new file mode 100755 index 00000000..832749f1 --- /dev/null +++ b/Examples/BushelCloud/Scripts/lint.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + if [ "$LINT_MODE" = "STRICT" ]; then + "$@" || ERRORS=$((ERRORS + 1)) + else + "$@" + fi +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Detect OS and set paths accordingly +if [ "$(uname)" = "Darwin" ]; then + DEFAULT_MINT_PATH="/opt/homebrew/bin/mint" +elif [ "$(uname)" = "Linux" ] && [ -n "$GITHUB_ACTIONS" ]; then + DEFAULT_MINT_PATH="$GITHUB_WORKSPACE/Mint/.mint/bin/mint" +elif [ "$(uname)" = "Linux" ]; then + DEFAULT_MINT_PATH="/usr/local/bin/mint" +else + echo "Unsupported operating system" + exit 1 +fi + +# Use environment MINT_CMD if set, otherwise use default path +MINT_CMD=${MINT_CMD:-$DEFAULT_MINT_PATH} + +export MINT_PATH="$PACKAGE_DIR/.mint" +MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" +MINT_RUN="$MINT_CMD run $MINT_ARGS" + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="" +fi + +pushd $PACKAGE_DIR +run_command $MINT_CMD bootstrap -m Mintfile + +if [ -z "$CI" ]; then + run_command $MINT_RUN swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + run_command $MINT_RUN swiftlint --fix +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command $MINT_RUN swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + run_command $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS + # Check for compilation errors + run_command swift build --build-tests +fi + +$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "BushelCloud" + +if [ -z "$CI" ]; then + run_command $MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Examples/Bushel/Scripts/setup-cloudkit-schema.sh b/Examples/BushelCloud/Scripts/setup-cloudkit-schema.sh similarity index 70% rename from Examples/Bushel/Scripts/setup-cloudkit-schema.sh rename to Examples/BushelCloud/Scripts/setup-cloudkit-schema.sh index 16d134b2..98dcbb01 100755 --- a/Examples/Bushel/Scripts/setup-cloudkit-schema.sh +++ b/Examples/BushelCloud/Scripts/setup-cloudkit-schema.sh @@ -1,10 +1,39 @@ #!/bin/bash # CloudKit Schema Setup Script -# This script imports the Bushel schema into your CloudKit container +# This script imports the BushelCloud schema into your CloudKit container set -eo pipefail +# Parse command line arguments +DRY_RUN=false +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --dry-run Validate schema without importing" + echo " --help, -h Show this help message" + echo "" + echo "Environment variables:" + echo " CLOUDKIT_CONTAINER_ID CloudKit container ID (default: iCloud.com.brightdigit.Bushel)" + echo " CLOUDKIT_TEAM_ID Apple Developer Team ID (10-character)" + echo " CLOUDKIT_ENVIRONMENT Environment (development or production, default: development)" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -12,8 +41,11 @@ YELLOW='\033[1;33m' NC='\033[0m' # No Color echo "========================================" -echo "CloudKit Schema Setup for Bushel" +echo "CloudKit Schema Setup for BushelCloud" echo "========================================" +if [ "$DRY_RUN" = true ]; then + echo "(DRY RUN MODE - No changes will be made)" +fi echo "" # Check if cktool is available @@ -47,7 +79,7 @@ if ! xcrun cktool get-teams 2>&1 | grep -qE "^[A-Z0-9]+:"; then echo " 7. Paste your Management Token when prompted" echo "" echo "Note: Management Token is for schema operations (cktool)." - echo " Server-to-Server Key is for runtime API operations (bushel-images sync)." + echo " Server-to-Server Key is for runtime API operations (bushel-cloud sync)." echo "" exit 1 fi @@ -108,6 +140,15 @@ fi echo "" +# Skip import if dry-run +if [ "$DRY_RUN" = true ]; then + echo "" + echo -e "${GREEN}✓✓✓ Dry run complete! ✓✓✓${NC}" + echo "" + echo "Schema validation passed. Run without --dry-run to import." + exit 0 +fi + # Confirm before import echo -e "${YELLOW}Warning: This will import the schema into your CloudKit container.${NC}" echo "This operation will create/modify record types in the $ENVIRONMENT environment." @@ -131,20 +172,27 @@ if xcrun cktool import-schema \ echo -e "${GREEN}✓✓✓ Schema import successful! ✓✓✓${NC}" echo "" echo "Your CloudKit container now has the following record types:" - echo " • RestoreImage" - echo " • XcodeVersion" - echo " • SwiftVersion" + echo " • RestoreImage - macOS restore images for virtualization" + echo " • XcodeVersion - Xcode releases with requirements" + echo " • SwiftVersion - Swift compiler versions" + echo " • DataSourceMetadata - Data source tracking metadata" echo "" echo "Next steps:" echo " 1. Get your Server-to-Server Key:" echo " a. Go to: https://icloud.developer.apple.com/dashboard/" echo " b. Navigate to: API Access → Server-to-Server Keys" echo " c. Create a new key and download the private key .pem file" + echo " d. Store it securely (e.g., ~/.cloudkit/bushel-private-key.pem)" + echo "" + echo " 2. Set environment variables:" + echo " export CLOUDKIT_KEY_ID=YOUR_KEY_ID" + echo " export CLOUDKIT_KEY_FILE=~/.cloudkit/bushel-private-key.pem" + echo " export CLOUDKIT_CONTAINER_ID=$CLOUDKIT_CONTAINER_ID" echo "" - echo " 2. Run 'bushel-images sync' with your credentials:" - echo " bushel-images sync --key-id YOUR_KEY_ID --key-file ./private-key.pem" + echo " 3. Run 'bushel-cloud sync' to populate data:" + echo " .build/debug/bushel-cloud sync --verbose" echo "" - echo " 3. Verify data in CloudKit Dashboard: https://icloud.developer.apple.com/" + echo " 4. Verify data in CloudKit Dashboard: https://icloud.developer.apple.com/" echo "" echo " Important: Never commit .pem files to version control!" echo "" diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/BushelCloudCLI.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/BushelCloudCLI.swift new file mode 100644 index 00000000..5d85bf39 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/BushelCloudCLI.swift @@ -0,0 +1,61 @@ +// +// BushelCloudCLI.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +@main +internal struct BushelCloudCLI { + internal static func main() async throws { + let args = Array(CommandLine.arguments.dropFirst()) + let command = args.first ?? "sync" + + switch command { + case "sync": + try await SyncCommand.run(args) + case "status": + try await StatusCommand.run(args) + case "list": + try await ListCommand.run(args) + case "export": + try await ExportCommand.run(args) + case "clear": + try await ClearCommand.run(args) + default: + print("Error: Unknown command '\(command)'") + print("") + print("Available commands:") + print(" sync - Sync data to CloudKit") + print(" status - Show CloudKit data source status") + print(" list - List CloudKit records") + print(" export - Export CloudKit data to JSON") + print(" clear - Clear all CloudKit records") + Foundation.exit(1) + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift new file mode 100644 index 00000000..28a2ed29 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift @@ -0,0 +1,97 @@ +// +// ClearCommand.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelCloudKit +import BushelFoundation +import BushelUtilities +import Foundation + +internal enum ClearCommand { + internal static func run(_ args: [String]) async throws { + // Load configuration using Swift Configuration + let loader = ConfigurationLoader() + let rawConfig = try await loader.loadConfiguration() + let config = try rawConfig.validated() + + // Enable verbose console output if requested + BushelUtilities.ConsoleOutput.isVerbose = config.clear?.verbose ?? false + + // Confirm deletion unless --yes flag is provided + let skipConfirmation = config.clear?.yes ?? false + if !skipConfirmation { + print("\n⚠️ WARNING: This will delete ALL records from CloudKit!") + print(" Container: \(config.cloudKit.containerID)") + print(" Database: public (development)") + print("") + print(" This operation cannot be undone.") + print("") + print(" Type 'yes' to confirm: ", terminator: "") + + guard let response = readLine(), response.lowercased() == "yes" else { + print("\n❌ Operation cancelled") + Foundation.exit(1) + } + } + + // Determine authentication method + let authMethod: CloudKitAuthMethod + if let pemString = config.cloudKit.privateKey { + authMethod = .pemString(pemString) + } else { + authMethod = .pemFile(path: config.cloudKit.privateKeyPath) + } + + // Create sync engine + let syncEngine = try SyncEngine( + containerIdentifier: config.cloudKit.containerID, + keyID: config.cloudKit.keyID, + authMethod: authMethod, + environment: config.cloudKit.environment + ) + + // Execute clear + do { + try await syncEngine.clear() + print("\n✅ All records have been deleted from CloudKit") + } catch { + printError(error) + Foundation.exit(1) + } + } + + // MARK: - Private Helpers + + private static func printError(_ error: any Error) { + print("\n❌ Clear failed: \(error.localizedDescription)") + print("\n💡 Troubleshooting:") + print(" • Verify your API token is valid") + print(" • Check your internet connection") + print(" • Ensure the CloudKit container exists") + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift new file mode 100644 index 00000000..13315b6b --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift @@ -0,0 +1,196 @@ +// +// ExportCommand.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelCloudKit +import BushelFoundation +import Foundation +import MistKit + +internal enum ExportCommand { + // MARK: - Export Types + + private struct ExportData: Codable { + let restoreImages: [RecordExport] + let xcodeVersions: [RecordExport] + let swiftVersions: [RecordExport] + } + + private struct RecordExport: Codable { + let recordName: String + let recordType: String + let fields: [String: String] + + init(from recordInfo: RecordInfo) { + self.recordName = recordInfo.recordName + self.recordType = recordInfo.recordType + self.fields = recordInfo.fields.mapValues { fieldValue in + String(describing: fieldValue) + } + } + } + + private enum ExportError: Error { + case encodingFailed + } + + // MARK: - Command Implementation + + internal static func run(_ args: [String]) async throws { + // Load configuration using Swift Configuration + let loader = ConfigurationLoader() + let rawConfig = try await loader.loadConfiguration() + let config = try rawConfig.validated() + + // Enable verbose console output if requested + ConsoleOutput.isVerbose = config.export?.verbose ?? false + + // Determine authentication method + let authMethod: CloudKitAuthMethod + if let pemString = config.cloudKit.privateKey { + authMethod = .pemString(pemString) + } else { + authMethod = .pemFile(path: config.cloudKit.privateKeyPath) + } + + // Create sync engine + let syncEngine = try SyncEngine( + containerIdentifier: config.cloudKit.containerID, + keyID: config.cloudKit.keyID, + authMethod: authMethod, + environment: config.cloudKit.environment + ) + + // Execute export + do { + let result = try await syncEngine.export() + let filtered = applyFilters(to: result, with: config.export) + let json = try encodeToJSON(filtered, pretty: config.export?.pretty ?? false) + + if let outputPath = config.export?.output { + try writeToFile(json, at: outputPath) + print("✅ Exported to: \(outputPath)") + } else { + print(json) + } + } catch { + printError(error) + Foundation.exit(1) + } + } + + // MARK: - Private Helpers + + private static func applyFilters( + to result: SyncEngine.ExportResult, + with exportConfig: ExportConfiguration? + ) -> SyncEngine.ExportResult { + guard let exportConfig = exportConfig else { + return result + } + + var restoreImages = result.restoreImages + var xcodeVersions = result.xcodeVersions + var swiftVersions = result.swiftVersions + + // Filter signed-only restore images + if exportConfig.signedOnly { + restoreImages = restoreImages.filter { record in + if case .int64(let isSigned) = record.fields["isSigned"] { + return isSigned != 0 + } + return false + } + } + + // Filter out betas + if exportConfig.noBetas { + restoreImages = restoreImages.filter { record in + if case .int64(let isPrerelease) = record.fields["isPrerelease"] { + return isPrerelease == 0 + } + return true + } + + xcodeVersions = xcodeVersions.filter { record in + if case .int64(let isPrerelease) = record.fields["isPrerelease"] { + return isPrerelease == 0 + } + return true + } + + swiftVersions = swiftVersions.filter { record in + if case .int64(let isPrerelease) = record.fields["isPrerelease"] { + return isPrerelease == 0 + } + return true + } + } + + return SyncEngine.ExportResult( + restoreImages: restoreImages, + xcodeVersions: xcodeVersions, + swiftVersions: swiftVersions + ) + } + + private static func encodeToJSON(_ result: SyncEngine.ExportResult, pretty: Bool) throws + -> String + { + let export = ExportData( + restoreImages: result.restoreImages.map(RecordExport.init), + xcodeVersions: result.xcodeVersions.map(RecordExport.init), + swiftVersions: result.swiftVersions.map(RecordExport.init) + ) + + let encoder = JSONEncoder() + if pretty { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + let data = try encoder.encode(export) + guard let json = String(data: data, encoding: .utf8) else { + throw ExportError.encodingFailed + } + + return json + } + + private static func writeToFile(_ content: String, at path: String) throws { + try content.write(toFile: path, atomically: true, encoding: .utf8) + } + + private static func printError(_ error: any Error) { + print("\n❌ Export failed: \(error.localizedDescription)") + print("\n💡 Troubleshooting:") + print(" • Verify your API token is valid") + print(" • Check your internet connection") + print(" • Ensure data has been synced to CloudKit") + print(" • Run 'bushel-cloud sync' first if needed") + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift new file mode 100644 index 00000000..ce4dced2 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift @@ -0,0 +1,81 @@ +// +// ListCommand.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelCloudKit +import BushelFoundation +import Foundation +import MistKit + +internal enum ListCommand { + internal static func run(_ args: [String]) async throws { + // Load configuration using Swift Configuration + let loader = ConfigurationLoader() + let rawConfig = try await loader.loadConfiguration() + let config = try rawConfig.validated() + + // Create CloudKit service + let cloudKitService: BushelCloudKitService + if let pemString = config.cloudKit.privateKey { + cloudKitService = try BushelCloudKitService( + containerIdentifier: config.cloudKit.containerID, + keyID: config.cloudKit.keyID, + pemString: pemString, + environment: config.cloudKit.environment + ) + } else { + cloudKitService = try BushelCloudKitService( + containerIdentifier: config.cloudKit.containerID, + keyID: config.cloudKit.keyID, + privateKeyPath: config.cloudKit.privateKeyPath, + environment: config.cloudKit.environment + ) + } + + // Determine what to list based on flags + let listConfig = config.list + let listAll = + !(listConfig?.restoreImages ?? false) + && !(listConfig?.xcodeVersions ?? false) + && !(listConfig?.swiftVersions ?? false) + + if listAll { + try await cloudKitService.listAllRecords() + } else { + if listConfig?.restoreImages ?? false { + try await cloudKitService.list(RestoreImageRecord.self) + } + if listConfig?.xcodeVersions ?? false { + try await cloudKitService.list(XcodeVersionRecord.self) + } + if listConfig?.swiftVersions ?? false { + try await cloudKitService.list(SwiftVersionRecord.self) + } + } + } +} diff --git a/Examples/Bushel/Sources/BushelImages/Commands/StatusCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift similarity index 53% rename from Examples/Bushel/Sources/BushelImages/Commands/StatusCommand.swift rename to Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift index e440d4ea..98b02d87 100644 --- a/Examples/Bushel/Sources/BushelImages/Commands/StatusCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift @@ -1,84 +1,64 @@ +// // StatusCommand.swift -// Created by Claude Code - -import ArgumentParser +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelCloudKit +import BushelFoundation import Foundation internal import MistKit -struct StatusCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "status", - abstract: "Show data source fetch status and metadata", - discussion: """ - Displays information about when each data source was last fetched, - when the source was last updated, record counts, and next eligible fetch time. - - This command queries CloudKit for DataSourceMetadata records to show - the current state of all data sources. - """ - ) - - // MARK: - Required Options - - @Option(name: .shortAndLong, help: "CloudKit container identifier") - var containerIdentifier: String = "iCloud.com.brightdigit.Bushel" - - @Option(name: .long, help: "Server-to-Server Key ID (or set CLOUDKIT_KEY_ID)") - var keyID: String = "" - - @Option(name: .long, help: "Path to private key .pem file (or set CLOUDKIT_PRIVATE_KEY_PATH)") - var keyFile: String = "" - - // MARK: - Display Options - - @Flag(name: .long, help: "Show only sources with errors") - var errorsOnly: Bool = false - - @Flag(name: .long, help: "Show detailed timing information") - var detailed: Bool = false - - @Flag(name: .long, help: "Disable log redaction for debugging (shows actual CloudKit field names in errors)") - var noRedaction: Bool = false - - // MARK: - Execution - - mutating func run() async throws { - // Disable log redaction for debugging if requested - if noRedaction { - setenv("MISTKIT_DISABLE_LOG_REDACTION", "1", 1) - } - - // Get Server-to-Server credentials from environment if not provided - let resolvedKeyID = keyID.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"] ?? "" : - keyID - - let resolvedKeyFile = keyFile.isEmpty ? - ProcessInfo.processInfo.environment["CLOUDKIT_PRIVATE_KEY_PATH"] ?? "" : - keyFile - - guard !resolvedKeyID.isEmpty, !resolvedKeyFile.isEmpty else { - print("❌ Error: CloudKit Server-to-Server Key credentials are required") - print("") - print(" Provide via command-line flags:") - print(" --key-id YOUR_KEY_ID --key-file ./private-key.pem") - print("") - print(" Or set environment variables:") - print(" export CLOUDKIT_KEY_ID=\"YOUR_KEY_ID\"") - print(" export CLOUDKIT_PRIVATE_KEY_PATH=\"./private-key.pem\"") - print("") - throw ExitCode.failure - } +internal enum StatusCommand { + internal static func run(_ args: [String]) async throws { + // Load configuration using Swift Configuration + let loader = ConfigurationLoader() + let rawConfig = try await loader.loadConfiguration() + let config = try rawConfig.validated() // Create CloudKit service - let cloudKitService = try BushelCloudKitService( - containerIdentifier: containerIdentifier, - keyID: resolvedKeyID, - privateKeyPath: resolvedKeyFile - ) + let cloudKitService: BushelCloudKitService + if let pemString = config.cloudKit.privateKey { + cloudKitService = try BushelCloudKitService( + containerIdentifier: config.cloudKit.containerID, + keyID: config.cloudKit.keyID, + pemString: pemString, + environment: config.cloudKit.environment + ) + } else { + cloudKitService = try BushelCloudKitService( + containerIdentifier: config.cloudKit.containerID, + keyID: config.cloudKit.keyID, + privateKeyPath: config.cloudKit.privateKeyPath, + environment: config.cloudKit.environment + ) + } // Load configuration to show intervals - let configuration = FetchConfiguration.loadFromEnvironment() + let configuration = config.fetch ?? FetchConfiguration.loadFromEnvironment() // Fetch all metadata records print("\n📊 Data Source Status") @@ -87,11 +67,12 @@ struct StatusCommand: AsyncParsableCommand { let allMetadata = try await fetchAllMetadata(cloudKitService: cloudKitService) if allMetadata.isEmpty { - print("\n No metadata records found. Run 'bushel-images sync' to populate metadata.") + print("\n No metadata records found. Run 'bushel-cloud sync' to populate metadata.") return } // Filter if needed + let errorsOnly = config.status?.errorsOnly ?? false let metadata = errorsOnly ? allMetadata.filter { $0.lastError != nil } : allMetadata if metadata.isEmpty, errorsOnly { @@ -100,7 +81,11 @@ struct StatusCommand: AsyncParsableCommand { } // Display metadata - for meta in metadata.sorted(by: { $0.recordTypeName < $1.recordTypeName || ($0.recordTypeName == $1.recordTypeName && $0.sourceName < $1.sourceName) }) { + let detailed = config.status?.detailed ?? false + for meta in metadata.sorted(by: { + $0.recordTypeName < $1.recordTypeName + || ($0.recordTypeName == $1.recordTypeName && $0.sourceName < $1.sourceName) + }) { printMetadata(meta, configuration: configuration, detailed: detailed) } @@ -109,15 +94,17 @@ struct StatusCommand: AsyncParsableCommand { // MARK: - Private Helpers - private func fetchAllMetadata(cloudKitService: BushelCloudKitService) async throws -> [DataSourceMetadata] { + private static func fetchAllMetadata(cloudKitService: BushelCloudKitService) async throws + -> [DataSourceMetadata] + { let records = try await cloudKitService.queryRecords(recordType: "DataSourceMetadata") var metadataList: [DataSourceMetadata] = [] for record in records { guard let sourceName = record.fields["sourceName"]?.stringValue, - let recordTypeName = record.fields["recordTypeName"]?.stringValue, - let lastFetchedAt = record.fields["lastFetchedAt"]?.dateValue + let recordTypeName = record.fields["recordTypeName"]?.stringValue, + let lastFetchedAt = record.fields["lastFetchedAt"]?.dateValue else { continue } @@ -143,7 +130,7 @@ struct StatusCommand: AsyncParsableCommand { return metadataList } - private func printMetadata( + private static func printMetadata( _ metadata: DataSourceMetadata, configuration: FetchConfiguration, detailed: Bool @@ -181,7 +168,9 @@ struct StatusCommand: AsyncParsableCommand { let timeUntilNext = nextFetchTime.timeIntervalSince(now) if timeUntilNext > 0 { - print(" Next Fetch: \(formatDate(nextFetchTime)) (in \(formatTimeInterval(timeUntilNext)))") + print( + " Next Fetch: \(formatDate(nextFetchTime)) (in \(formatTimeInterval(timeUntilNext)))" + ) } else { print(" Next Fetch: ✅ Ready now") } @@ -199,28 +188,28 @@ struct StatusCommand: AsyncParsableCommand { } } - private func formatDate(_ date: Date) -> String { + private static func formatDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short return formatter.string(from: date) } - private func formatTimeInterval(_ interval: TimeInterval) -> String { + private static func formatTimeInterval(_ interval: TimeInterval) -> String { let absInterval = abs(interval) if absInterval < 60 { return "\(Int(absInterval))s" - } else if absInterval < 3600 { + } else if absInterval < 3_600 { let minutes = Int(absInterval / 60) return "\(minutes)m" - } else if absInterval < 86400 { - let hours = Int(absInterval / 3600) - let minutes = Int((absInterval.truncatingRemainder(dividingBy: 3600)) / 60) + } else if absInterval < 86_400 { + let hours = Int(absInterval / 3_600) + let minutes = Int((absInterval.truncatingRemainder(dividingBy: 3_600)) / 60) return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" } else { - let days = Int(absInterval / 86400) - let hours = Int((absInterval.truncatingRemainder(dividingBy: 86400)) / 3600) + let days = Int(absInterval / 86_400) + let hours = Int((absInterval.truncatingRemainder(dividingBy: 86_400)) / 3_600) return hours > 0 ? "\(days)d \(hours)h" : "\(days)d" } } diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift new file mode 100644 index 00000000..2e2dab7e --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift @@ -0,0 +1,175 @@ +// +// SyncCommand.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelCloudKit +import BushelFoundation +import BushelUtilities +import Foundation + +internal enum SyncCommand { + internal static func run(_ args: [String]) async throws { + // Load configuration using Swift Configuration + let loader = ConfigurationLoader() + let rawConfig = try await loader.loadConfiguration() + let config = try rawConfig.validated() + + // Enable verbose console output if requested + BushelUtilities.ConsoleOutput.isVerbose = config.sync?.verbose ?? false + + // Build sync options from configuration + let options = buildSyncOptions(from: config.sync) + + // Get fetch configuration (already loaded by ConfigurationLoader) + let fetchConfiguration = config.fetch ?? FetchConfiguration.loadFromEnvironment() + + // Determine authentication method + let authMethod: CloudKitAuthMethod + if let pemString = config.cloudKit.privateKey { + authMethod = .pemString(pemString) + } else { + authMethod = .pemFile(path: config.cloudKit.privateKeyPath) + } + + // Create sync engine + let syncEngine = try SyncEngine( + containerIdentifier: config.cloudKit.containerID, + keyID: config.cloudKit.keyID, + authMethod: authMethod, + environment: config.cloudKit.environment, + configuration: fetchConfiguration + ) + + // Execute sync + do { + let result = try await syncEngine.sync(options: options) + + // Write JSON to file if path specified + if let outputFile = config.sync?.jsonOutputFile { + let json = try result.toJSON(pretty: true) + try json.write(toFile: outputFile, atomically: true, encoding: .utf8) + BushelCloudKit.ConsoleOutput.info("✅ JSON output written to: \(outputFile)") + } + + // Always show human-readable summary + printSuccess(result) + } catch { + printError(error) + Foundation.exit(1) + } + } + + // MARK: - Private Helpers + + private static func buildSyncOptions(from syncConfig: SyncConfiguration?) + -> SyncEngine.SyncOptions + { + guard let syncConfig = syncConfig else { + return SyncEngine.SyncOptions() + } + + var pipelineOptions = DataSourcePipeline.Options() + + // Apply filters based on flags + if syncConfig.restoreImagesOnly { + pipelineOptions.includeXcodeVersions = false + pipelineOptions.includeSwiftVersions = false + } else if syncConfig.xcodeOnly { + pipelineOptions.includeRestoreImages = false + pipelineOptions.includeSwiftVersions = false + } else if syncConfig.swiftOnly { + pipelineOptions.includeRestoreImages = false + pipelineOptions.includeXcodeVersions = false + } + + if syncConfig.noBetas { + pipelineOptions.includeBetaReleases = false + } + + if syncConfig.noAppleWiki { + pipelineOptions.includeTheAppleWiki = false + } + + // Apply throttling options + pipelineOptions.force = syncConfig.force + pipelineOptions.specificSource = syncConfig.source + + return SyncEngine.SyncOptions( + dryRun: syncConfig.dryRun, + pipelineOptions: pipelineOptions + ) + } + + private static func printSuccess(_ result: SyncEngine.DetailedSyncResult) { + print("\n" + String(repeating: "=", count: 60)) + print("✅ Sync Summary") + print(String(repeating: "=", count: 60)) + + printTypeResult("RestoreImages", result.restoreImages) + printTypeResult("XcodeVersions", result.xcodeVersions) + printTypeResult("SwiftVersions", result.swiftVersions) + + let totalCreated = result.restoreImages.created + result.xcodeVersions.created + result.swiftVersions.created + let totalUpdated = result.restoreImages.updated + result.xcodeVersions.updated + result.swiftVersions.updated + let totalFailed = result.restoreImages.failed + result.xcodeVersions.failed + result.swiftVersions.failed + + print(String(repeating: "-", count: 60)) + print("TOTAL:") + print(" ✨ Created: \(totalCreated)") + print(" 🔄 Updated: \(totalUpdated)") + if totalFailed > 0 { + print(" ❌ Failed: \(totalFailed)") + } + print(String(repeating: "=", count: 60)) + print("\n💡 Next: Use 'bushel-cloud export' to view the synced data") + } + + private static func printTypeResult(_ name: String, _ typeResult: SyncEngine.TypeSyncResult) { + print("\n\(name):") + print(" ✨ Created: \(typeResult.created)") + print(" 🔄 Updated: \(typeResult.updated)") + if typeResult.failed > 0 { + print(" ❌ Failed: \(typeResult.failed)") + if !typeResult.failedRecordNames.isEmpty { + print(" Records: \(typeResult.failedRecordNames.prefix(5).joined(separator: ", "))") + if typeResult.failedRecordNames.count > 5 { + print(" ... and \(typeResult.failedRecordNames.count - 5) more") + } + } + } + } + + private static func printError(_ error: any Error) { + print("\n❌ Sync failed: \(error.localizedDescription)") + print("\n💡 Troubleshooting:") + print(" • Verify your API token is valid") + print(" • Check your internet connection") + print(" • Ensure the CloudKit container exists") + print(" • Verify external data sources are accessible") + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/BushelCloud.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/BushelCloud.md new file mode 100644 index 00000000..8954bfd0 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/BushelCloud.md @@ -0,0 +1,56 @@ +# ``BushelCloud`` + +A CloudKit demonstration tool showcasing MistKit's Server-to-Server authentication and batch operations. + +## Overview + +BushelCloud is a command-line tool that demonstrates how to use MistKit to interact with CloudKit Web Services. It fetches macOS restore images, Xcode versions, and Swift compiler versions from multiple sources and syncs them to CloudKit using Server-to-Server authentication. + +This is an **example application** designed for developers learning CloudKit integration patterns with Swift. + +## Topics + +### Getting Started + +- +- + +### Architecture + +- +- + +### Tutorials + +- +- + +### Core Components + +- ``BushelCloudKitService`` +- ``SyncEngine`` +- ``DataSourcePipeline`` + +### Data Models + +- ``RestoreImageRecord`` +- ``XcodeVersionRecord`` +- ``SwiftVersionRecord`` +- ``DataSourceMetadata`` +- ``CloudKitRecord`` + +### Data Sources + +- ``IPSWFetcher`` +- ``AppleDBFetcher`` +- ``XcodeReleasesFetcher`` +- ``SwiftVersionFetcher`` +- ``MESUFetcher`` + +### Commands + +- ``SyncCommand`` +- ``ExportCommand`` +- ``ClearCommand`` +- ``ListCommand`` +- ``StatusCommand`` diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md new file mode 100644 index 00000000..b66632b7 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md @@ -0,0 +1,153 @@ +# CloudKit Integration Patterns + +Learn how BushelCloud uses MistKit for CloudKit Web Services. + +## Overview + +BushelCloud demonstrates production-ready patterns for using MistKit to interact with CloudKit Web Services using Server-to-Server authentication. + +## Server-to-Server Authentication + +Initialize ``BushelCloudKitService`` with ECDSA private key: + +```swift +let tokenManager = try ServerToServerAuthManager( + keyID: "your-key-id", + pemString: pemFileContents +) + +let service = try CloudKitService( + containerIdentifier: "iCloud.com.company.App", + tokenManager: tokenManager, + environment: .development, + database: .public +) +``` + +Authentication tokens are automatically refreshed by MistKit. + +## Batch Operations + +CloudKit limits operations to 200 per request. BushelCloud handles this automatically: + +```swift +let batches = operations.chunked(into: 200) +for batch in batches { + let results = try await service.modifyRecords(batch) + // Handle results... +} +``` + +See ``SyncEngine/uploadRecords(_:recordType:)`` for the complete implementation. + +## Record Creation Pattern + +All domain models implement ``CloudKitRecord``: + +```swift +protocol CloudKitRecord { + static var cloudKitRecordType: String { get } + func toCloudKitFields() -> [String: FieldValue] + static func from(recordInfo: RecordInfo) -> Self? +} +``` + +Example implementation: + +```swift +extension RestoreImageRecord: CloudKitRecord { + static var cloudKitRecordType: String { "RestoreImage" } + + func toCloudKitFields() -> [String: FieldValue] { + [ + "version": .string(version), + "buildNumber": .string(buildNumber), + "releaseDate": .date(releaseDate), + "isPrerelease": FieldValue(booleanValue: isPrerelease), + // ... more fields + ] + } +} +``` + +## Field Type Conversions + +CloudKit field types map to Swift types: + +| Swift Type | CloudKit Type | Example | +|------------|---------------|---------| +| `String` | `.string()` | `.string("macOS 14.0")` | +| `Int64` | `.int64()` | `.int64(fileSize)` | +| `Date` | `.date()` | `.date(releaseDate)` | +| `Bool` | `.int64()` | `FieldValue(booleanValue: true)` | +| Reference | `.reference()` | `.reference(Reference(recordName: "RestoreImage-23C71"))` | + +**Note**: Booleans are stored as INT64 (0 = false, 1 = true). + +## Date Handling + +CloudKit dates use **milliseconds since epoch**. MistKit's `FieldValue.date()` handles conversion: + +```swift +// MistKit converts automatically +let field: FieldValue = .date(Date()) +``` + +## Reference Fields + +Create relationships using record names: + +```swift +fields["minimumMacOS"] = .reference( + FieldValue.Reference(recordName: "RestoreImage-23C71") +) +``` + +Read references: + +```swift +if case .reference(let ref) = fieldValue { + let recordName = ref.recordName +} +``` + +## Error Handling + +Check for partial failures in batch operations: + +```swift +let results = try await service.modifyRecords(operations) +for result in results { + if result.isError { + logger.error("Failed: \\(result.serverErrorCode ?? "unknown")") + } +} +``` + +## Verbose Mode + +Enable verbose logging to see MistKit operations: + +```swift +BushelLogger.shared.enableVerbose() +``` + +This logs: +- API requests and responses +- Batch operation details +- Field mappings and conversions +- Authentication token refresh + +## Key Classes + +- ``BushelCloudKitService`` - Service wrapper for BushelCloud operations +- ``SyncEngine`` - Upload orchestration +- ``CloudKitFieldMapping`` - Type conversion utilities + +## Best Practices + +1. **Batch wisely**: Stay under 200 operations per request +2. **Order matters**: Upload dependencies first (SwiftVersion before XcodeVersion) +3. **Handle partials**: Check `RecordInfo.isError` for each result +4. **Use references**: Link related records with CloudKit references +5. **Verbose development**: Use `--verbose` flag during development diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitSetup.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitSetup.md new file mode 100644 index 00000000..2fb16708 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitSetup.md @@ -0,0 +1,359 @@ +# CloudKit Server-to-Server Authentication Setup + +Configure CloudKit credentials and schema for BushelCloud. + +## Overview + +BushelCloud uses CloudKit's Server-to-Server authentication, which allows command-line tools and servers to access CloudKit without user credentials. This guide explains how to set up the required authentication keys and CloudKit schema. + +## Quick Start + +If you just want to get started quickly: + +1. Generate an S2S key pair and register it in CloudKit Dashboard +2. Set environment variables for the key ID and private key file +3. Run the automated schema setup script +4. Start syncing data + +For detailed instructions, continue reading below. + +--- + +## Part 1: Server-to-Server Authentication + +### What is Server-to-Server Authentication? + +Server-to-Server (S2S) authentication allows backend services, scripts, and CLI tools to access CloudKit **without requiring a signed-in iCloud user**. This is essential for: + +- Automated data syncing from external APIs +- Scheduled batch operations +- Server-side data processing +- Command-line tools that manage CloudKit data + +### Step 1: Generate the Key Pair + +Open Terminal and generate an ECDSA P-256 key pair using OpenSSL: + +```bash +# Generate private key +openssl ecparam -name prime256v1 -genkey -noout -out eckey.pem + +# Extract public key +openssl ec -in eckey.pem -pubout -out eckey_pub.pem +``` + +**Important:** Keep `eckey.pem` (private key) **secure and confidential**. Never commit it to version control. + +### Step 2: Register Key in CloudKit Dashboard + +1. **Navigate to CloudKit Dashboard** + - Go to [CloudKit Dashboard](https://icloud.developer.apple.com/) + - Select your Team + - Select your Container (or create one if needed) + +2. **Navigate to Server-to-Server Keys** + - In the left sidebar, under "Settings" + - Click "Server-to-Server Keys" + +3. **Create New Server-to-Server Key** + - Click the "+" button to create a new key + - **Name:** Give it a descriptive name (e.g., "BushelCloud Demo Key") + - **Public Key:** Paste the contents of `eckey_pub.pem` + +4. **Save and Record Key ID** + - After saving, CloudKit will display a **Key ID** (long hexadecimal string) + - **Copy this Key ID** - you'll need it for authentication + - Example: `3e76ace055d2e3881a4e9c862dd1119ea85717bd743c1c8c15d95b2280cd93ab` + +### Step 3: Secure Key Storage + +Store your private key securely: + +```bash +# Create secure directory +mkdir -p ~/.cloudkit +chmod 700 ~/.cloudkit + +# Move private key to secure location +mv eckey.pem ~/.cloudkit/bushel-private-key.pem +chmod 600 ~/.cloudkit/bushel-private-key.pem +``` + +### Step 4: Configure Environment Variables + +Set the following environment variables: + +```bash +export CLOUDKIT_KEY_ID="your-key-id-here" +export CLOUDKIT_KEY_FILE="$HOME/.cloudkit/bushel-private-key.pem" +export CLOUDKIT_CONTAINER_ID="iCloud.com.brightdigit.Bushel" # Optional +``` + +Add these to your shell profile (~/.zshrc or ~/.bashrc) for persistence: + +```bash +# Add to ~/.zshrc +echo 'export CLOUDKIT_KEY_ID="your-key-id-here"' >> ~/.zshrc +echo 'export CLOUDKIT_KEY_FILE="$HOME/.cloudkit/bushel-private-key.pem"' >> ~/.zshrc +echo 'export CLOUDKIT_CONTAINER_ID="iCloud.com.brightdigit.Bushel"' >> ~/.zshrc +``` + +### Step 5: Verify Setup + +Test your configuration with a status check: + +```bash +bushel-cloud status --verbose +``` + +This should connect to CloudKit and display your container configuration. + +--- + +## Part 2: CloudKit Schema Setup + +BushelCloud requires specific record types in your CloudKit public database. You can set up the schema automatically or manually. + +### Option 1: Automated Setup (Recommended) + +Use the provided script to automatically import the schema: + +```bash +# Set required environment variables +export CLOUDKIT_CONTAINER_ID="iCloud.com.yourcompany.Bushel" +export CLOUDKIT_TEAM_ID="YOUR_TEAM_ID" +export CLOUDKIT_ENVIRONMENT="development" # or "production" + +# Run the setup script +./Scripts/setup-cloudkit-schema.sh +``` + +**Prerequisites:** +- Xcode Command Line Tools installed +- CloudKit Management Token (the script will guide you through obtaining one) +- Your Team ID (10-character identifier from Apple Developer account) + +### Option 2: Manual Schema Creation + +If you prefer manual setup, follow these steps: + +#### Get a Management Token + +Management tokens allow `cktool` to modify your CloudKit schema: + +1. Open [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/) +2. Select your container +3. Click your profile icon (top right) +4. Select "API Access" → "CloudKit Web Services" +5. Click "Create Management Token" +6. Give it a name: "BushelCloud Schema Management" +7. **Copy the token** (you won't see it again!) +8. Save it using `cktool`: + +```bash +xcrun cktool save-token +# Paste token when prompted +``` + +#### Import the Schema + +```bash +xcrun cktool import-schema \ + --team-id YOUR_TEAM_ID \ + --container-id iCloud.com.yourcompany.Bushel \ + --environment development \ + --file schema.ckdb +``` + +#### Verify Schema Import + +```bash +xcrun cktool export-schema \ + --team-id YOUR_TEAM_ID \ + --container-id iCloud.com.yourcompany.Bushel \ + --environment development \ + > current-schema.ckdb + +# Check the permissions +cat current-schema.ckdb | grep -A 2 "GRANT" +``` + +You should see: +```text +GRANT READ, CREATE, WRITE TO "_creator", +GRANT READ, CREATE, WRITE TO "_icloud", +GRANT READ TO "_world" +``` + +--- + +## Required Record Types + +BushelCloud requires these record types in your CloudKit public database: + +### RestoreImage + +macOS restore images for virtualization: + +| Field | Type | Description | +|-------|------|-------------| +| `version` | STRING | macOS version (e.g., "15.0") | +| `buildNumber` | STRING | Build number (e.g., "24A335") - unique key | +| `releaseDate` | TIMESTAMP | Release date | +| `downloadURL` | STRING | Download URL for IPSW file | +| `fileSize` | INT64 | File size in bytes | +| `sha256Hash` | STRING | SHA-256 checksum | +| `sha1Hash` | STRING | SHA-1 checksum | +| `isSigned` | INT64 | Signing status (0=no, 1=yes) | +| `isPrerelease` | INT64 | Prerelease status (0=no, 1=yes) | +| `source` | STRING | Data source (e.g., "ipsw.me") | +| `notes` | STRING | Optional metadata | +| `sourceUpdatedAt` | TIMESTAMP | Last update from source | + +### XcodeVersion + +Xcode releases and build numbers: + +| Field | Type | Description | +|-------|------|-------------| +| `version` | STRING | Xcode version (e.g., "16.0") | +| `buildNumber` | STRING | Build number (e.g., "16A242") - unique key | +| `releaseDate` | TIMESTAMP | Release date | +| `downloadURL` | STRING | Download URL (optional) | +| `isPrerelease` | INT64 | Prerelease status (0=no, 1=yes) | +| `source` | STRING | Data source | +| `minimumMacOS` | REFERENCE | Reference to RestoreImage record | +| `swiftVersion` | REFERENCE | Reference to SwiftVersion record | +| `notes` | STRING | Optional metadata | + +### SwiftVersion + +Swift compiler versions: + +| Field | Type | Description | +|-------|------|-------------| +| `version` | STRING | Swift version (e.g., "6.0.2") - unique key | +| `releaseDate` | TIMESTAMP | Release date | +| `downloadURL` | STRING | Download URL (optional) | +| `source` | STRING | Data source | +| `notes` | STRING | Optional metadata | + +### DataSourceMetadata + +Fetch metadata and throttling information: + +| Field | Type | Description | +|-------|------|-------------| +| `sourceName` | STRING | Data source name | +| `recordTypeName` | STRING | Record type being tracked | +| `lastFetchedAt` | TIMESTAMP | Last fetch time | +| `sourceUpdatedAt` | TIMESTAMP | Source last updated | +| `recordCount` | INT64 | Number of records fetched | +| `fetchDurationSeconds` | INT64 | Fetch duration | +| `lastError` | STRING | Last error message (optional) | + +--- + +## Critical Schema Permissions + +**Important:** For Server-to-Server authentication to work, your schema must grant permissions to **both** `_creator` and `_icloud` roles: + +```text +GRANT READ, CREATE, WRITE TO "_creator", +GRANT READ, CREATE, WRITE TO "_icloud", +GRANT READ TO "_world" +``` + +**Why both are required:** +- `_creator` - S2S keys authenticate as the developer/creator +- `_icloud` - Required for public database operations +- `_world` - Allows public read access (optional, but recommended for shared data) + +**Common mistake:** Only granting to one role results in `ACCESS_DENIED` errors. + +--- + +## Troubleshooting + +### "Authentication failed" (HTTP 401) + +**Cause:** Invalid or revoked Key ID + +**Solution:** +1. Generate a new S2S key in CloudKit Dashboard +2. Update `CLOUDKIT_KEY_ID` environment variable +3. Verify private key file is correct + +### "ACCESS_DENIED - CREATE operation not permitted" + +**Cause:** Schema permissions don't grant CREATE to both `_creator` and `_icloud` + +**Solution:** +1. Export current schema: `xcrun cktool export-schema ...` +2. Verify permissions show both `_creator` and `_icloud` +3. If missing, update schema file and re-import + +### "Private key file not found" + +**Cause:** File doesn't exist at specified path + +**Solution:** +- Use absolute path: `$HOME/.cloudkit/bushel-private-key.pem` +- Verify file exists: `ls -la $CLOUDKIT_KEY_FILE` +- Check file permissions: `chmod 600 $CLOUDKIT_KEY_FILE` + +### "Schema validation failed: Was expecting DEFINE" + +**Cause:** Schema file missing `DEFINE SCHEMA` header + +**Solution:** +Add this line at the top of your `schema.ckdb` file: +```text +DEFINE SCHEMA +``` + +### "Container not found" + +**Cause:** Container ID doesn't match CloudKit Dashboard + +**Solution:** +1. Verify container ID in CloudKit Dashboard +2. Check `CLOUDKIT_CONTAINER_ID` environment variable +3. Ensure Team ID is correct + +--- + +## Security Notes + +**Never commit** your private key (.pem file) to version control: + +```gitignore +# .gitignore +*.pem +*.p8 +.env +.cloudkit/ +``` + +**Best practices:** +- Store credentials in `~/.cloudkit/` with restrictive permissions (600) +- Use environment variables, not hardcoded values +- Generate separate keys for development and production +- Rotate keys periodically (every 6-12 months) + +--- + +## Next Steps + +After setting up authentication and schema: + +- - Start syncing data to CloudKit +- - Understand how data flows through BushelCloud +- - Export CloudKit data to JSON + +## Additional Resources + +- [CloudKit Web Services Documentation](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/) +- [Server-to-Server Keys Guide](https://developer.apple.com/documentation/cloudkit) +- [cktool Reference](https://keith.github.io/xcode-man-pages/cktool.1.html) +- [CloudKit Dashboard](https://icloud.developer.apple.com/) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/DataFlow.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/DataFlow.md new file mode 100644 index 00000000..ab1bfe49 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/DataFlow.md @@ -0,0 +1,89 @@ +# Data Flow Architecture + +Understand how data moves through BushelCloud from external sources to CloudKit. + +## Overview + +BushelCloud follows a three-phase pipeline architecture to fetch, transform, and upload data to CloudKit. + +## Pipeline Phases + +### Phase 1: Fetch + +The ``DataSourcePipeline`` fetches data from multiple external sources in parallel: + +1. **IPSW.me** - macOS restore images via IPSWDownloads library +2. **AppleDB.dev** - Comprehensive restore image database +3. **XcodeReleases.com** - Xcode versions and build info +4. **swift.org** - Swift compiler releases +5. **MESU** - Apple's official software update metadata +6. **Mr. Macintosh** - Community-maintained release archive +7. **VirtualBuddy** - Real-time TSS signing status verification + +Each fetcher returns domain-specific records implementing the ``CloudKitRecord`` protocol. + +### Phase 2: Transform + +Data transformation includes: + +- **Deduplication**: Merge duplicate records using build numbers as unique keys +- **Reference Resolution**: Create CloudKit references between related records +- **Field Mapping**: Convert Swift types to CloudKit `FieldValue` types + +Key deduplication rules: +- MESU and VirtualBuddy are authoritative for signing status +- AppleDB backfills missing SHA-256 hashes +- Most recent `sourceUpdatedAt` timestamp wins + +### Phase 3: Upload + +The ``SyncEngine`` uploads records to CloudKit: + +1. **Batch Operations**: Records are chunked into 200-operation batches (CloudKit limit) +2. **Dependency Ordering**: Upload in order: SwiftVersion → RestoreImage → XcodeVersion +3. **Error Handling**: Partial failures are logged with CloudKit error details + +## Record Relationships + +``` +SwiftVersion (no dependencies) + ↑ + | CKReference + | +RestoreImage (no dependencies) + ↑ + | CKReference (minimumMacOS, swiftVersion) + | +XcodeVersion +``` + +XcodeVersion records reference both RestoreImage (for minimum macOS) and SwiftVersion (for bundled Swift compiler). + +## CloudKit Integration + +See for details on: +- Server-to-Server authentication +- Batch operation handling +- Record creation patterns +- Error handling strategies + +## Key Classes + +- ``DataSourcePipeline`` - Orchestrates fetching from all sources +- ``SyncEngine`` - Manages CloudKit upload process +- ``BushelCloudKitService`` - Wraps MistKit with BushelCloud-specific operations +- ``CloudKitRecord`` - Protocol for domain models + +## Observability + +Enable verbose mode to see detailed operation logs: + +```bash +bushel-cloud sync --verbose +``` + +This shows: +- Fetch timing for each source +- Deduplication merge decisions +- CloudKit batch operations +- Field mappings and conversions diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/ExportingData.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/ExportingData.md new file mode 100644 index 00000000..a14445fa --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/ExportingData.md @@ -0,0 +1,274 @@ +# Exporting CloudKit Data + +Learn how to export CloudKit records to JSON format for analysis and backup. + +## Overview + +The `export` command downloads all records from CloudKit and saves them to a JSON file. This is useful for backup, data analysis, or migrating to other systems. + +## Prerequisites + +Before exporting, ensure you've completed to configure your CloudKit credentials. + +## Basic Export + +Export all records to a JSON file: + +```bash +bushel-cloud export --output data.json +``` + +This creates a JSON file containing all RestoreImage, XcodeVersion, SwiftVersion, and DataSourceMetadata records. + +## Verbose Output + +See detailed operation logs: + +```bash +bushel-cloud export --output data.json --verbose +``` + +Verbose mode shows: +- Query operations for each record type +- Number of records fetched +- CloudKit API request/response details +- Field deserialization process + +## JSON Structure + +The exported JSON file has this structure: + +```json +{ + "restoreImages": [ + { + "recordName": "RestoreImage-23C71", + "recordType": "RestoreImage", + "fields": { + "version": {"value": "14.0", "type": "STRING"}, + "buildNumber": {"value": "23C71", "type": "STRING"}, + "releaseDate": {"value": 1699920000000, "type": "TIMESTAMP"}, + "downloadURL": {"value": "https://...", "type": "STRING"}, + "fileSize": {"value": 13958643712, "type": "INT64"}, + "sha256Hash": {"value": "abc123...", "type": "STRING"}, + "isSigned": {"value": 1, "type": "INT64"}, + "isPrerelease": {"value": 0, "type": "INT64"} + }, + "created": {...}, + "modified": {...} + } + ], + "xcodeVersions": [...], + "swiftVersions": [...], + "dataSourceMetadata": [...] +} +``` + +## Field Types + +CloudKit field types are preserved in the export: + +| CloudKit Type | JSON Representation | Example | +|---------------|---------------------|---------| +| `STRING` | String value | `"macOS 14.0"` | +| `INT64` | Number value | `13958643712` | +| `TIMESTAMP` | Milliseconds since epoch | `1699920000000` | +| `REFERENCE` | Record name string | `"RestoreImage-23C71"` | + +**Note**: Booleans are stored as INT64 (0 = false, 1 = true) in CloudKit. + +## Date Handling + +CloudKit dates are exported as **milliseconds since Unix epoch**: + +```json +{ + "releaseDate": {"value": 1699920000000, "type": "TIMESTAMP"} +} +``` + +To convert in JavaScript: + +```javascript +const date = new Date(1699920000000); +// Mon Nov 13 2023 19:46:40 GMT-0800 +``` + +To convert in Python: + +```python +from datetime import datetime +date = datetime.fromtimestamp(1699920000000 / 1000) +# 2023-11-13 19:46:40 +``` + +## Reference Fields + +CloudKit references are exported as record names: + +```json +{ + "minimumMacOS": { + "value": { + "recordName": "RestoreImage-23C71", + "action": "NONE" + }, + "type": "REFERENCE" + } +} +``` + +Use the `recordName` to look up related records in the exported data. + +## Querying Exported Data + +Use `jq` to query the exported JSON: + +```bash +# Count restore images +jq '.restoreImages | length' data.json + +# Find all Xcode 15 versions +jq '.xcodeVersions[] | select(.fields.version.value | startswith("15"))' data.json + +# List all signed restore images +jq '.restoreImages[] | select(.fields.isSigned.value == 1) | .fields.version.value' data.json + +# Get all Swift 5.9.x versions +jq '.swiftVersions[] | select(.fields.version.value | startswith("5.9"))' data.json +``` + +## Backup Strategy + +Use export for regular CloudKit backups: + +```bash +# Daily backup with timestamp +bushel-cloud export --output "backup-$(date +%Y%m%d).json" + +# Keep last 7 days of backups +find . -name 'backup-*.json' -mtime +7 -delete +``` + +## Data Analysis + +Import the JSON into your favorite tools: + +**Python/Pandas**: +```python +import json +import pandas as pd + +with open('data.json') as f: + data = json.load(f) + +# Convert to DataFrame +df = pd.DataFrame([ + { + 'version': r['fields']['version']['value'], + 'build': r['fields']['buildNumber']['value'], + 'size': r['fields']['fileSize']['value'] + } + for r in data['restoreImages'] +]) +``` + +**JavaScript/Node.js**: +```javascript +const fs = require('fs'); +const data = JSON.parse(fs.readFileSync('data.json', 'utf8')); + +// Filter prerelease versions +const prereleases = data.restoreImages.filter( + r => r.fields.isPrerelease.value === 1 +); +``` + +## Record Metadata + +Each record includes CloudKit system fields: + +```json +{ + "created": { + "timestamp": 1699920000000, + "userRecordName": "_server-to-server", + "deviceID": "..." + }, + "modified": { + "timestamp": 1699920100000, + "userRecordName": "_server-to-server", + "deviceID": "..." + } +} +``` + +Use these timestamps to track when records were created or last updated. + +## Comparing Exports + +Diff two exports to see changes: + +```bash +# Export before sync +bushel-cloud export --output before.json + +# Run sync +bushel-cloud sync + +# Export after sync +bushel-cloud export --output after.json + +# Compare (requires jq) +diff <(jq -S . before.json) <(jq -S . after.json) +``` + +## Performance + +Export performance depends on record count: + +- **< 100 records**: Near-instant +- **100-1000 records**: 1-5 seconds +- **1000+ records**: May take 10+ seconds + +CloudKit queries are paginated automatically by MistKit. + +## Example Workflow + +```bash +# 1. Export current state +bushel-cloud export --output baseline.json --verbose + +# 2. Check record counts +jq '{ + restoreImages: (.restoreImages | length), + xcodeVersions: (.xcodeVersions | length), + swiftVersions: (.swiftVersions | length) +}' baseline.json + +# 3. Analyze data +jq '.restoreImages[] | select(.fields.isSigned.value == 0) | .fields.version.value' baseline.json + +# 4. Save for comparison later +mv baseline.json exports/baseline-$(date +%Y%m%d).json +``` + +## Limitations + +- **No Filtering**: Exports all records (cannot filter by date, version, etc.) +- **No Format Options**: Only JSON format supported +- **In-Memory Processing**: Large datasets may consume significant memory + +These are intentional limitations for this demonstration tool. + +## Next Steps + +- - Upload data to CloudKit +- - Understand CloudKit field types +- - Learn about record relationships + +## Key Classes + +- ``ExportCommand`` - CLI command implementation +- ``BushelCloudKitService`` - CloudKit query operations +- ``CloudKitRecord`` - Protocol for record conversion diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/GettingStarted.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/GettingStarted.md new file mode 100644 index 00000000..7df5176c --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/GettingStarted.md @@ -0,0 +1,64 @@ +# Getting Started with BushelCloud + +Learn how to build, configure, and run BushelCloud. + +## Overview + +BushelCloud is a command-line demonstration tool that shows how to use MistKit to interact with CloudKit Web Services. This guide will help you get started with building and running the tool. + +## Prerequisites + +- Swift 6.1 or later +- macOS 14.0+ (for CloudKit functionality) +- A CloudKit container with Server-to-Server authentication configured +- Mint (for development tools): `brew install mint` + +## Building the Project + +Build BushelCloud using Swift Package Manager: + +```bash +swift build +``` + +Or use the provided Makefile: + +```bash +make build +``` + +The executable will be available at `.build/debug/bushel-cloud`. + +## Quick Test + +Run a dry-run sync to test without uploading to CloudKit: + +```bash +.build/debug/bushel-cloud sync --dry-run --verbose +``` + +This fetches data from external sources without uploading to CloudKit, and shows verbose output explaining what's happening. + +## Next Steps + +- - Configure CloudKit Server-to-Server authentication +- - Learn how to sync data to CloudKit +- - Export CloudKit data to JSON + +## Development + +For development with linting and testing: + +```bash +make test # Run tests +make lint # Run linting +make xcode # Generate Xcode project +``` + +Use Dev Containers for Linux development: + +```bash +make docker-test # Test in Docker +``` + +See the README for complete development instructions. diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/SyncingData.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/SyncingData.md new file mode 100644 index 00000000..016ab83c --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/SyncingData.md @@ -0,0 +1,179 @@ +# Syncing Data to CloudKit + +Learn how to fetch data from external sources and upload it to CloudKit. + +## Overview + +The `sync` command is BushelCloud's primary operation. It fetches macOS restore images, Xcode versions, and Swift compiler versions from multiple sources, then uploads them to CloudKit using Server-to-Server authentication. + +## Prerequisites + +Before syncing, ensure you've completed to configure your CloudKit credentials. + +## Basic Sync + +Run a full sync to CloudKit: + +```bash +bushel-cloud sync +``` + +This performs a three-phase process: + +1. **Fetch**: Downloads data from all sources in parallel +2. **Transform**: Deduplicates and resolves references +3. **Upload**: Batches operations and uploads to CloudKit + +## Dry Run Mode + +Test fetching without uploading to CloudKit: + +```bash +bushel-cloud sync --dry-run +``` + +**Use dry run to**: +- Test external API connectivity +- Preview data before uploading +- Debug data source issues +- Verify deduplication logic + +Dry run completes phases 1 and 2 but skips the upload phase. + +## Verbose Output + +See detailed operation logs: + +```bash +bushel-cloud sync --verbose +``` + +Verbose mode shows: +- Fetch timing for each data source +- Deduplication merge decisions +- CloudKit batch operations +- Field mappings and type conversions +- Authentication token refresh + +**Combine with dry run** for development: + +```bash +bushel-cloud sync --dry-run --verbose +``` + +## Data Sources + +BushelCloud fetches from seven external sources: + +| Source | Data Type | Priority | +|--------|-----------|----------| +| **IPSW.me** | Restore images | Standard | +| **AppleDB.dev** | Restore images | Backfills SHA-256 | +| **MESU** | Restore images | Authoritative for signing | +| **VirtualBuddy** | Restore images | Authoritative for signing | +| **Mr. Macintosh** | Restore images | Historical data | +| **XcodeReleases.com** | Xcode versions | Primary | +| **swift.org** | Swift versions | Official releases | + +All fetchers run concurrently. Fetch timing is logged in verbose mode. + +## Deduplication + +Multiple sources provide overlapping data. BushelCloud merges records using these rules: + +**Restore Images**: +- **Unique Key**: Build number (e.g., "23C71") +- **Authoritative Sources**: Signing status from MESU and VirtualBuddy overrides other sources +- **AppleDB Backfill**: SHA-256 hashes filled from AppleDB when missing +- **Timestamp Wins**: Most recent `sourceUpdatedAt` wins for conflicts + +**Xcode Versions**: +- **Unique Key**: Build number (e.g., "15C500b") +- **Single Source**: Currently only XcodeReleases.com provides data + +**Swift Versions**: +- **Unique Key**: Version string (e.g., "5.9.2") +- **Single Source**: Official swift.org releases + +See ``DataSourcePipeline`` for merge implementation details. + +## Record Relationships + +Records are uploaded in dependency order: + +``` +1. SwiftVersion (no dependencies) +2. RestoreImage (no dependencies) +3. XcodeVersion (references SwiftVersion and RestoreImage) +``` + +This ensures CloudKit references are valid when XcodeVersion records are created. + +## Batch Operations + +CloudKit limits operations to 200 per request. BushelCloud automatically: + +1. Chunks operations into batches of 200 +2. Uploads each batch sequentially +3. Logs progress for each batch +4. Checks for partial failures + +Batch details appear in verbose output. + +## Error Handling + +**Partial Failures**: If some records fail in a batch, successful records are still created. Failed records are logged with CloudKit error details. + +**Network Issues**: Sync will fail fast if external APIs are unreachable. Check verbose output for specific HTTP errors. + +**Authentication Errors**: Invalid CloudKit credentials will fail immediately. Verify your API token and private key setup. + +## Example Session + +```bash +# First time: use dry run with verbose to preview +bushel-cloud sync --dry-run --verbose + +# Review output, then sync for real +bushel-cloud sync --verbose + +# Check what was uploaded +bushel-cloud list +``` + +## Production Usage + +For production deployments: + +1. **Schedule Regular Syncs**: Run daily or weekly via cron/systemd +2. **Monitor Logs**: Capture output for debugging +3. **Use Verbose Initially**: Disable verbose after confirming operations +4. **Check CloudKit Dashboard**: Verify records appear correctly + +Example cron job: + +```bash +# Daily sync at 3 AM +0 3 * * * /usr/local/bin/bushel-cloud sync >> /var/log/bushel-cloud.log 2>&1 +``` + +## Known Limitations + +- **No Duplicate Detection**: Running sync multiple times creates duplicate records +- **No Incremental Sync**: Always fetches all data from sources +- **No Conflict Resolution**: Concurrent syncs may cause conflicts + +These are intentional limitations for this demonstration tool. + +## Next Steps + +- - Export CloudKit data to JSON +- - Understand CloudKit patterns used +- - Deep dive into the data pipeline + +## Key Classes + +- ``SyncCommand`` - CLI command implementation +- ``SyncEngine`` - Upload orchestration +- ``DataSourcePipeline`` - Fetch coordination +- ``BushelCloudKitService`` - CloudKit operations wrapper diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitError.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitError.swift new file mode 100644 index 00000000..3546abdf --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitError.swift @@ -0,0 +1,74 @@ +// +// BushelCloudKitError.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during BushelCloudKitService operations +public enum BushelCloudKitError: LocalizedError { + case privateKeyFileNotFound(path: String) + case privateKeyFileReadFailed(path: String, error: any Error) + case invalidPEMFormat(reason: String, suggestion: String) + case invalidMetadataRecord(recordName: String) + + public var errorDescription: String? { + switch self { + case .privateKeyFileNotFound(let path): + return "Private key file not found at path: \(path)" + case .privateKeyFileReadFailed(let path, let error): + return "Failed to read private key file at \(path): \(error.localizedDescription)" + case .invalidPEMFormat(let reason, let suggestion): + return """ + Invalid PEM format: \(reason) + + Suggestion: \(suggestion) + + Expected format: + -----BEGIN PRIVATE KEY----- + [base64 encoded key data] + -----END PRIVATE KEY----- + """ + case .invalidMetadataRecord(let recordName): + return "Invalid DataSourceMetadata record: \(recordName) (missing required fields)" + } + } + + public var recoverySuggestion: String? { + switch self { + case .invalidPEMFormat(_, let suggestion): + return suggestion + case .privateKeyFileNotFound(let path): + return """ + Ensure the file exists at \(path) or set CLOUDKIT_PRIVATE_KEY_PATH environment variable. + To generate a new key, visit: https://icloud.developer.apple.com/dashboard/ + """ + default: + return nil + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift new file mode 100644 index 00000000..48be625f --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -0,0 +1,287 @@ +// +// BushelCloudKitService.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import BushelLogging +public import Foundation +import Logging +public import MistKit + +#if canImport(FelinePineSwift) + import FelinePineSwift +#endif + +/// CloudKit service wrapper for Bushel demo operations +/// +/// **Tutorial**: This demonstrates MistKit's Server-to-Server authentication pattern: +/// 1. Load ECDSA private key from .pem file +/// 2. Create ServerToServerAuthManager with key ID and PEM string +/// 3. Initialize CloudKitService with the auth manager +/// 4. Use service.modifyRecords() and service.queryRecords() for operations +/// +/// This pattern allows command-line tools and servers to access CloudKit without user authentication. +public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCollection { + public typealias RecordTypeSetType = RecordTypeSet + + // MARK: - CloudKitRecordCollection + + /// All CloudKit record types managed by this service (using variadic generics) + public static let recordTypes = RecordTypeSet( + RestoreImageRecord.self, + XcodeVersionRecord.self, + SwiftVersionRecord.self, + DataSourceMetadata.self + ) + + private let service: CloudKitService + + // MARK: - Initialization + + /// Initialize CloudKit service with Server-to-Server authentication + /// + /// **MistKit Pattern**: Server-to-Server authentication requires: + /// 1. Key ID from CloudKit Dashboard → API Access → Server-to-Server Keys + /// 2. Private key .pem file downloaded when creating the key + /// 3. Container identifier (begins with "iCloud.") + /// + /// - Parameters: + /// - containerIdentifier: CloudKit container ID (e.g., "iCloud.com.company.App") + /// - keyID: Server-to-Server Key ID from CloudKit Dashboard + /// - privateKeyPath: Path to the private key .pem file + /// - environment: CloudKit environment (.development or .production, defaults to .development) + /// - Throws: Error if the private key file cannot be read or is invalid + public init( + containerIdentifier: String, + keyID: String, + privateKeyPath: String, + environment: Environment = .development + ) throws { + // Read PEM file from disk + guard FileManager.default.fileExists(atPath: privateKeyPath) else { + throw BushelCloudKitError.privateKeyFileNotFound(path: privateKeyPath) + } + + let pemString: String + do { + pemString = try String(contentsOfFile: privateKeyPath, encoding: .utf8) + } catch { + throw BushelCloudKitError.privateKeyFileReadFailed(path: privateKeyPath, error: error) + } + + // Validate PEM format before using it + try PEMValidator.validate(pemString) + + // Create Server-to-Server authentication manager + let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + pemString: pemString + ) + + self.service = try CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: environment, + database: .public + ) + } + + /// Initialize CloudKit service with Server-to-Server authentication using PEM string + /// + /// **CI/CD Pattern**: This initializer accepts PEM content directly from environment variables, + /// eliminating the need for temporary file creation in GitHub Actions or other CI/CD environments. + /// + /// - Parameters: + /// - containerIdentifier: CloudKit container ID (e.g., "iCloud.com.company.App") + /// - keyID: Server-to-Server Key ID from CloudKit Dashboard + /// - pemString: PEM file content as string (including headers/footers) + /// - environment: CloudKit environment (.development or .production, defaults to .development) + /// - Throws: Error if PEM string is invalid or authentication fails + public init( + containerIdentifier: String, + keyID: String, + pemString: String, + environment: Environment = .development + ) throws { + // Validate PEM format BEFORE passing to MistKit + // This provides better error messages than MistKit's internal validation + try PEMValidator.validate(pemString) + + // Create Server-to-Server authentication manager directly from PEM string + let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + pemString: pemString + ) + + self.service = try CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: environment, + database: .public + ) + } + + // MARK: - RecordManaging Protocol Requirements + + /// Query all records of a given type + public func queryRecords(recordType: String) async throws -> [RecordInfo] { + try await service.queryRecords(recordType: recordType, limit: 200) + } + + /// Fetch existing record names for create/update classification + /// + /// This method queries CloudKit to get all existing record names for a given type. + /// Used to classify sync operations as creates (new records) vs updates (existing records). + /// + /// - Parameter recordType: The CloudKit record type to query + /// - Returns: Set of existing record names in CloudKit + public func fetchExistingRecordNames(recordType: String) async throws -> Set { + Self.logger.debug("Pre-fetching existing record names for \(recordType)") + + let records = try await queryRecords(recordType: recordType) + let recordNames = Set(records.map(\.recordName)) + + Self.logger.debug("Found \(recordNames.count) existing \(recordType) records") + return recordNames + } + + /// Execute operations in batches without tracking creates/updates + /// + /// This is the protocol-conforming version that doesn't track create vs update. + /// For detailed tracking, use the overload with `classification` parameter. + public func executeBatchOperations( + _ operations: [RecordOperation], + recordType: String + ) async throws { + // Create empty classification (no tracking) + let classification = OperationClassification(proposedRecords: [], existingRecords: []) + _ = try await executeBatchOperations( + operations, recordType: recordType, classification: classification + ) + } + + /// Execute operations in batches with detailed create/update tracking + /// + /// **MistKit Pattern**: CloudKit has a 200 operations/request limit. + /// This method chunks operations and calls service.modifyRecords() for each batch. + /// + /// - Parameters: + /// - operations: CloudKit operations to execute + /// - recordType: Record type name for logging + /// - classification: Pre-computed classification of operations as creates vs updates + /// - Returns: Detailed sync result with creates/updates/failures breakdown + public func executeBatchOperations( + _ operations: [RecordOperation], + recordType: String, + classification: OperationClassification + ) async throws -> SyncEngine.TypeSyncResult { + let batchSize = 200 + let batches = operations.chunked(into: batchSize) + + ConsoleOutput.print("Syncing \(operations.count) \(recordType) record(s) in \(batches.count) batch(es)...") + Self.logger.debug( + """ + CloudKit batch limit: 200 operations/request. \ + Using \(batches.count) batch(es) for \(operations.count) records. + """ + ) + Self.logger.debug( + "Classification: \(classification.creates.count) creates, \(classification.updates.count) updates" + ) + + var totalCreated = 0 + var totalUpdated = 0 + var totalFailed = 0 + var failedRecordNames: [String] = [] + + for (index, batch) in batches.enumerated() { + print(" Batch \(index + 1)/\(batches.count): \(batch.count) records...") + Self.logger.debug( + "Calling MistKit service.modifyRecords() with \(batch.count) RecordOperation objects" + ) + + let results = try await service.modifyRecords(batch) + + Self.logger.debug( + "Received \(results.count) RecordInfo responses from CloudKit" + ) + + // Track results based on classification + for result in results { + if result.isError { + totalFailed += 1 + failedRecordNames.append(result.recordName) + Self.logger.debug( + "Error: recordName=\(result.recordName), reason=\(result.recordType)" + ) + } else { + // Classify as create or update based on pre-fetch + if classification.creates.contains(result.recordName) { + totalCreated += 1 + } else if classification.updates.contains(result.recordName) { + totalUpdated += 1 + } + } + } + + let batchSucceeded = results.filter { !$0.isError }.count + let batchFailed = results.count - batchSucceeded + + if batchFailed > 0 { + print(" ⚠️ \(batchFailed) operations failed (see verbose logs for details)") + print(" ✓ \(batchSucceeded) records confirmed") + } else { + Self.logger.info( + "CloudKit confirmed \(batchSucceeded) records" + ) + } + } + + ConsoleOutput.print("\n📊 \(recordType) Sync Summary:") + ConsoleOutput.print(" ✨ Created: \(totalCreated) records") + ConsoleOutput.print(" 🔄 Updated: \(totalUpdated) records") + if totalFailed > 0 { + print(" ❌ Failed: \(totalFailed) operations") + Self.logger.debug( + "Use --verbose flag to see CloudKit error details (serverErrorCode, reason, etc.)" + ) + } + + return SyncEngine.TypeSyncResult( + created: totalCreated, + updated: totalUpdated, + failed: totalFailed, + failedRecordNames: failedRecordNames + ) + } +} + +// MARK: - Loggable Conformance +extension BushelCloudKitService: Loggable { + public static let loggingCategory: BushelLogging.Category = .data +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift new file mode 100644 index 00000000..c7d00980 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift @@ -0,0 +1,53 @@ +// +// CloudKitAuthMethod.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Authentication method for CloudKit Server-to-Server +/// +/// Provides type-safe authentication credential handling with two patterns: +/// - `.pemString`: For CI/CD environments (GitHub Actions secrets) +/// - `.pemFile`: For local development (file on disk) +public enum CloudKitAuthMethod: Sendable { + /// PEM content provided as string (CI/CD pattern) + /// + /// **Usage**: Pass PEM content from environment variables or secrets + /// ```swift + /// let method = .pemString(pemContentFromEnvironment) + /// ``` + case pemString(String) + + /// PEM content loaded from file path (local development pattern) + /// + /// **Usage**: Pass path to .pem file on disk + /// ```swift + /// let method = .pemFile(path: "~/.cloudkit/bushel-private-key.pem") + /// ``` + case pemFile(path: String) +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/OperationClassification.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/OperationClassification.swift new file mode 100644 index 00000000..5fc95db4 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/OperationClassification.swift @@ -0,0 +1,64 @@ +// +// OperationClassification.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Classifies CloudKit operations as creates or updates +/// +/// Since CloudKit's `.forceReplace` operation doesn't distinguish between +/// creating new records and updating existing ones, we pre-fetch existing +/// record names and classify operations before execution. +public struct OperationClassification: Sendable { + /// Record names that will be created (don't exist in CloudKit) + public let creates: Set + + /// Record names that will be updated (already exist in CloudKit) + public let updates: Set + + /// Initialize by comparing proposed records against existing records + /// + /// - Parameters: + /// - proposedRecords: Record names we want to sync + /// - existingRecords: Record names that already exist in CloudKit + public init(proposedRecords: [String], existingRecords: Set) { + var creates = Set() + var updates = Set() + + for recordName in proposedRecords { + if existingRecords.contains(recordName) { + updates.insert(recordName) + } else { + creates.insert(recordName) + } + } + + self.creates = creates + self.updates = updates + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift new file mode 100644 index 00000000..e81ea290 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift @@ -0,0 +1,99 @@ +// +// PEMValidator.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Validates PEM format for CloudKit Server-to-Server private keys +internal enum PEMValidator { + /// Validates a PEM string has proper structure and encoding + /// + /// **Checks performed:** + /// 1. Contains BEGIN PRIVATE KEY header + /// 2. Contains END PRIVATE KEY footer + /// 3. Has content between headers + /// 4. Content is valid base64 + /// + /// **Why validate?** + /// - Provides clear error messages before attempting CloudKit operations + /// - Catches common copy/paste errors (truncation, missing markers) + /// - Prevents cryptic errors from MistKit's ServerToServerAuthManager + /// + /// - Parameter pemString: The PEM-formatted private key string + /// - Throws: BushelCloudKitError.invalidPEMFormat with specific reason and recovery suggestion + internal static func validate(_ pemString: String) throws { + let trimmed = pemString.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check for BEGIN header + guard trimmed.contains("-----BEGIN") && trimmed.contains("PRIVATE KEY-----") else { + throw BushelCloudKitError.invalidPEMFormat( + reason: "Missing '-----BEGIN PRIVATE KEY-----' header", + suggestion: """ + Ensure you copied the entire PEM file including the header line. \ + Re-download from CloudKit Dashboard if needed. + """ + ) + } + + // Check for END footer + guard trimmed.contains("-----END") && trimmed.contains("PRIVATE KEY-----") else { + throw BushelCloudKitError.invalidPEMFormat( + reason: "Missing '-----END PRIVATE KEY-----' footer", + suggestion: """ + The PEM file may have been truncated during copy/paste. \ + Ensure you copied the entire file including the footer line. + """ + ) + } + + // Extract content between headers + let lines = trimmed.components(separatedBy: .newlines) + let contentLines = lines.filter { line in + !line.contains("BEGIN") && !line.contains("END") && !line.isEmpty + } + + guard !contentLines.isEmpty else { + throw BushelCloudKitError.invalidPEMFormat( + reason: "PEM file contains no key data between headers", + suggestion: "The key file may be corrupted or empty. Re-download from CloudKit Dashboard." + ) + } + + // Validate base64 encoding + let base64Content = contentLines.joined() + guard Data(base64Encoded: base64Content) != nil else { + throw BushelCloudKitError.invalidPEMFormat( + reason: "PEM content is not valid base64 encoding", + suggestion: """ + The key file may be corrupted. \ + Ensure you used a text editor (not binary editor) and the file is UTF-8 encoded. + """ + ) + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/RecordManaging+Query.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/RecordManaging+Query.swift new file mode 100644 index 00000000..91b54fb2 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/RecordManaging+Query.swift @@ -0,0 +1,37 @@ +// +// RecordManaging+Query.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import Foundation +public import MistKit + +extension RecordManaging { + // MARK: - Query Operations + // Query helpers can be added here as needed +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift new file mode 100644 index 00000000..552c51b7 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift @@ -0,0 +1,114 @@ +// +// SyncEngine+Export.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelLogging +import BushelUtilities +import Logging +public import MistKit + +#if canImport(FelinePineSwift) + import FelinePineSwift +#endif + +// MARK: - Export Operations + +extension SyncEngine { + // MARK: - Export Result Type + + public struct ExportResult { + public let restoreImages: [RecordInfo] + public let xcodeVersions: [RecordInfo] + public let swiftVersions: [RecordInfo] + + public init( + restoreImages: [RecordInfo], xcodeVersions: [RecordInfo], swiftVersions: [RecordInfo] + ) { + self.restoreImages = restoreImages + self.xcodeVersions = xcodeVersions + self.swiftVersions = swiftVersions + } + } + + /// Export all records from CloudKit to a structured format + public func export() async throws -> ExportResult { + ConsoleOutput.print("\n" + String(repeating: "=", count: 60)) + BushelUtilities.ConsoleOutput.info("Exporting data from CloudKit") + ConsoleOutput.print(String(repeating: "=", count: 60)) + Self.logger.info("Exporting CloudKit data") + + Self.logger.debug( + "Using MistKit queryRecords() to fetch all records of each type from the public database" + ) + + ConsoleOutput.print("\n📥 Fetching RestoreImage records...") + Self.logger.debug( + "Querying CloudKit for recordType: 'RestoreImage' with limit: 200" + ) + let restoreImages = try await cloudKitService.queryRecords(recordType: "RestoreImage") + Self.logger.debug( + "Retrieved \(restoreImages.count) RestoreImage records" + ) + + ConsoleOutput.print("📥 Fetching XcodeVersion records...") + Self.logger.debug( + "Querying CloudKit for recordType: 'XcodeVersion' with limit: 200" + ) + let xcodeVersions = try await cloudKitService.queryRecords(recordType: "XcodeVersion") + Self.logger.debug( + "Retrieved \(xcodeVersions.count) XcodeVersion records" + ) + + ConsoleOutput.print("📥 Fetching SwiftVersion records...") + Self.logger.debug( + "Querying CloudKit for recordType: 'SwiftVersion' with limit: 200" + ) + let swiftVersions = try await cloudKitService.queryRecords(recordType: "SwiftVersion") + Self.logger.debug( + "Retrieved \(swiftVersions.count) SwiftVersion records" + ) + + ConsoleOutput.print("\n✅ Exported:") + ConsoleOutput.print(" • \(restoreImages.count) restore images") + ConsoleOutput.print(" • \(xcodeVersions.count) Xcode versions") + ConsoleOutput.print(" • \(swiftVersions.count) Swift versions") + + Self.logger.debug( + """ + MistKit returns RecordInfo structs with record metadata. \ + Use .fields to access CloudKit field values. + """ + ) + + return ExportResult( + restoreImages: restoreImages, + xcodeVersions: xcodeVersions, + swiftVersions: swiftVersions + ) + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift new file mode 100644 index 00000000..eb41ab45 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift @@ -0,0 +1,383 @@ +// +// SyncEngine.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import BushelLogging +public import BushelUtilities +public import Foundation +import Logging +public import MistKit + +#if canImport(FelinePineSwift) + import FelinePineSwift +#endif + +/// Orchestrates the complete sync process from data sources to CloudKit +/// +/// **Tutorial**: This demonstrates the typical flow for CloudKit data syncing: +/// 1. Fetch data from external sources +/// 2. Transform to CloudKit records +/// 3. Batch upload using MistKit +/// +/// Use `--verbose` flag to see detailed MistKit API usage. +public struct SyncEngine: Sendable { + // MARK: - Configuration + + public struct SyncOptions: Sendable { + public var dryRun: Bool = false + public var pipelineOptions: DataSourcePipeline.Options = .init() + + public init(dryRun: Bool = false, pipelineOptions: DataSourcePipeline.Options = .init()) { + self.dryRun = dryRun + self.pipelineOptions = pipelineOptions + } + } + + // MARK: - Result Types + + public struct SyncResult: Sendable { + public let restoreImagesCount: Int + public let xcodeVersionsCount: Int + public let swiftVersionsCount: Int + + public init(restoreImagesCount: Int, xcodeVersionsCount: Int, swiftVersionsCount: Int) { + self.restoreImagesCount = restoreImagesCount + self.xcodeVersionsCount = xcodeVersionsCount + self.swiftVersionsCount = swiftVersionsCount + } + } + + /// Detailed sync result with per-type breakdown of creates/updates/failures + public struct DetailedSyncResult: Sendable, Codable { + public let restoreImages: TypeSyncResult + public let xcodeVersions: TypeSyncResult + public let swiftVersions: TypeSyncResult + + public init( + restoreImages: TypeSyncResult, xcodeVersions: TypeSyncResult, swiftVersions: TypeSyncResult + ) { + self.restoreImages = restoreImages + self.xcodeVersions = xcodeVersions + self.swiftVersions = swiftVersions + } + + /// Convert to JSON string + public func toJSON(pretty: Bool = false) throws -> String { + let encoder = JSONEncoder() + if pretty { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8) ?? "" + } + } + + /// Per-type sync statistics + public struct TypeSyncResult: Sendable, Codable { + public let created: Int + public let updated: Int + public let failed: Int + public let failedRecordNames: [String] + + public var total: Int { + created + updated + failed + } + + public var succeeded: Int { + created + updated + } + + public init(created: Int, updated: Int, failed: Int, failedRecordNames: [String]) { + self.created = created + self.updated = updated + self.failed = failed + self.failedRecordNames = failedRecordNames + } + } + + // MARK: - Properties + + internal let cloudKitService: BushelCloudKitService + internal let pipeline: DataSourcePipeline + + // MARK: - Initialization + + /// Initialize sync engine with CloudKit credentials + /// + /// **Flexible Authentication**: Supports both file-based and string-based PEM content: + /// - `.pemString`: For CI/CD environments (GitHub Actions secrets) + /// - `.pemFile`: For local development (file on disk) + /// + /// **Environment Separation**: Use separate keys for development and production: + /// - Development: Safe for testing, free API calls, can clear data freely + /// - Production: Real user data, requires careful key management + /// + /// - Parameters: + /// - containerIdentifier: CloudKit container ID + /// - keyID: Server-to-Server Key ID + /// - authMethod: Authentication method (`.pemString` or `.pemFile`) + /// - environment: CloudKit environment (.development or .production, defaults to .development) + /// - configuration: Fetch configuration for data sources + /// - Throws: Error if authentication credentials are invalid or missing + public init( + containerIdentifier: String, + keyID: String, + authMethod: CloudKitAuthMethod, + environment: Environment = .development, + configuration: FetchConfiguration = FetchConfiguration.loadFromEnvironment() + ) throws { + // Initialize CloudKit service based on auth method + let service: BushelCloudKitService + switch authMethod { + case .pemString(let pem): + service = try BushelCloudKitService( + containerIdentifier: containerIdentifier, + keyID: keyID, + pemString: pem, + environment: environment + ) + case .pemFile(let path): + service = try BushelCloudKitService( + containerIdentifier: containerIdentifier, + keyID: keyID, + privateKeyPath: path, + environment: environment + ) + } + + self.cloudKitService = service + self.pipeline = DataSourcePipeline( + configuration: configuration + ) + } + + // MARK: - Sync Operations + + /// Execute full sync from all data sources to CloudKit + /// + /// This method now tracks detailed statistics about creates, updates, and failures + /// for each record type, providing better visibility into sync operations. + /// + /// - Parameter options: Sync options including dry-run mode + /// - Returns: Detailed sync result with per-type breakdown + public func sync(options: SyncOptions = SyncOptions()) async throws -> DetailedSyncResult { + ConsoleOutput.print("\n" + String(repeating: "=", count: 60)) + BushelUtilities.ConsoleOutput.info("Starting Bushel CloudKit Sync") + ConsoleOutput.print(String(repeating: "=", count: 60)) + Self.logger.info("Sync started") + + if options.dryRun { + BushelUtilities.ConsoleOutput.info("DRY RUN MODE - No changes will be made to CloudKit") + Self.logger.info("Sync running in dry-run mode") + } + + Self.logger.debug( + "Using MistKit Server-to-Server authentication for bulk record operations" + ) + + // Step 1: Fetch from all data sources + ConsoleOutput.print("\n📥 Step 1: Fetching data from external sources...") + Self.logger.debug( + "Initializing data source pipeline to fetch from ipsw.me, TheAppleWiki, MESU, and other sources" + ) + + let fetchResult = try await pipeline.fetch(options: options.pipelineOptions) + + Self.logger.debug( + "Data fetch complete. Beginning deduplication and merge phase." + ) + Self.logger.debug( + "Multiple data sources may have overlapping data. The pipeline deduplicates by version+build number." + ) + + let totalRecords = + fetchResult.restoreImages.count + fetchResult.xcodeVersions.count + + fetchResult.swiftVersions.count + + ConsoleOutput.print("\n📊 Data Summary:") + ConsoleOutput.print(" RestoreImages: \(fetchResult.restoreImages.count)") + ConsoleOutput.print(" XcodeVersions: \(fetchResult.xcodeVersions.count)") + ConsoleOutput.print(" SwiftVersions: \(fetchResult.swiftVersions.count)") + ConsoleOutput.print(" ─────────────────────") + ConsoleOutput.print(" Total: \(totalRecords) records") + + Self.logger.debug( + "Records ready for CloudKit upload: \(totalRecords) total" + ) + + // Step 2: Sync to CloudKit (unless dry run) + if !options.dryRun { + print("\n☁️ Step 2: Syncing to CloudKit...") + Self.logger.debug( + "Using MistKit to batch upload records to CloudKit public database" + ) + + // Pre-fetch existing records in parallel + print(" Pre-fetching existing records for create/update classification...") + async let existingSwift = cloudKitService.fetchExistingRecordNames( + recordType: SwiftVersionRecord.cloudKitRecordType + ) + async let existingRestore = cloudKitService.fetchExistingRecordNames( + recordType: RestoreImageRecord.cloudKitRecordType + ) + async let existingXcode = cloudKitService.fetchExistingRecordNames( + recordType: XcodeVersionRecord.cloudKitRecordType + ) + + let (swiftNames, restoreNames, xcodeNames) = try await ( + existingSwift, existingRestore, existingXcode + ) + + Self.logger.debug( + """ + Pre-fetch complete: \(swiftNames.count) Swift, \ + \(restoreNames.count) Restore, \(xcodeNames.count) Xcode + """ + ) + + // Classify operations for each type + let swiftClassification = OperationClassification( + proposedRecords: fetchResult.swiftVersions.map(\.recordName), + existingRecords: swiftNames + ) + let restoreClassification = OperationClassification( + proposedRecords: fetchResult.restoreImages.map(\.recordName), + existingRecords: restoreNames + ) + let xcodeClassification = OperationClassification( + proposedRecords: fetchResult.xcodeVersions.map(\.recordName), + existingRecords: xcodeNames + ) + + Self.logger.debug( + "Classification complete. Ready to sync in dependency order." + ) + + // Sync each type with classification tracking (in dependency order) + // SwiftVersion and RestoreImage first (no dependencies) + // XcodeVersion last (references the other two) + let swiftResult = try await syncRecords( + fetchResult.swiftVersions, + classification: swiftClassification + ) + let restoreResult = try await syncRecords( + fetchResult.restoreImages, + classification: restoreClassification + ) + let xcodeResult = try await syncRecords( + fetchResult.xcodeVersions, + classification: xcodeClassification + ) + + print("\n" + String(repeating: "=", count: 60)) + BushelUtilities.ConsoleOutput.success("Sync completed successfully!") + print(String(repeating: "=", count: 60)) + Self.logger.info("Sync completed successfully") + + return DetailedSyncResult( + restoreImages: restoreResult, + xcodeVersions: xcodeResult, + swiftVersions: swiftResult + ) + } else { + print("\n⏭️ Step 2: Skipped (dry run)") + print(" Would sync:") + print(" • \(fetchResult.restoreImages.count) restore images") + print(" • \(fetchResult.xcodeVersions.count) Xcode versions") + print(" • \(fetchResult.swiftVersions.count) Swift versions") + Self.logger.debug( + "Dry run mode: No CloudKit operations performed" + ) + + print("\n" + String(repeating: "=", count: 60)) + BushelUtilities.ConsoleOutput.success("Dry run completed!") + print(String(repeating: "=", count: 60)) + + // Return empty result for dry run + return DetailedSyncResult( + restoreImages: TypeSyncResult(created: 0, updated: 0, failed: 0, failedRecordNames: []), + xcodeVersions: TypeSyncResult(created: 0, updated: 0, failed: 0, failedRecordNames: []), + swiftVersions: TypeSyncResult(created: 0, updated: 0, failed: 0, failedRecordNames: []) + ) + } + } + + /// Helper method to sync one record type + /// + /// This replaces the use of MistKit's `syncAllRecords()` extension method, + /// giving us full control over result tracking. + /// + /// - Parameters: + /// - records: Records to sync + /// - classification: Classification of operations as creates vs updates + /// - Returns: Sync result for this record type + private func syncRecords( + _ records: [T], + classification: OperationClassification + ) async throws -> TypeSyncResult { + guard !records.isEmpty else { + return TypeSyncResult(created: 0, updated: 0, failed: 0, failedRecordNames: []) + } + + let operations = records.map { record in + RecordOperation( + operationType: .forceReplace, + recordType: T.cloudKitRecordType, + recordName: record.recordName, + fields: record.toCloudKitFields() + ) + } + + return try await cloudKitService.executeBatchOperations( + operations, + recordType: T.cloudKitRecordType, + classification: classification + ) + } + + /// Delete all records from CloudKit + public func clear() async throws { + ConsoleOutput.print("\n" + String(repeating: "=", count: 60)) + BushelUtilities.ConsoleOutput.info("Clearing all CloudKit data") + ConsoleOutput.print(String(repeating: "=", count: 60)) + Self.logger.info("Clearing all CloudKit records") + + try await cloudKitService.deleteAllRecords() + + ConsoleOutput.print("\n" + String(repeating: "=", count: 60)) + BushelUtilities.ConsoleOutput.success("Clear completed successfully!") + ConsoleOutput.print(String(repeating: "=", count: 60)) + Self.logger.info("Clear completed successfully") + } +} + +// MARK: - Loggable Conformance +extension SyncEngine: Loggable { + public static let loggingCategory: BushelLogging.Category = .application +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift new file mode 100644 index 00000000..8ff93192 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift @@ -0,0 +1,130 @@ +// +// BushelConfiguration.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +import Foundation +import MistKit + +// MARK: - Configuration Error + +/// Errors that can occur during configuration validation +public struct ConfigurationError: Error, Sendable { + public let message: String + public let key: String? + + public init(_ message: String, key: String? = nil) { + self.message = message + self.key = key + } +} + +// MARK: - Root Configuration + +/// Root configuration containing all subsystem configurations +public struct BushelConfiguration: Sendable { + public var cloudKit: CloudKitConfiguration? + public var virtualBuddy: VirtualBuddyConfiguration? + public var fetch: FetchConfiguration? + public var sync: SyncConfiguration? + public var export: ExportConfiguration? + public var status: StatusConfiguration? + public var list: ListConfiguration? + public var clear: ClearConfiguration? + + public init( + cloudKit: CloudKitConfiguration? = nil, + virtualBuddy: VirtualBuddyConfiguration? = nil, + fetch: FetchConfiguration? = nil, + sync: SyncConfiguration? = nil, + export: ExportConfiguration? = nil, + status: StatusConfiguration? = nil, + list: ListConfiguration? = nil, + clear: ClearConfiguration? = nil + ) { + self.cloudKit = cloudKit + self.virtualBuddy = virtualBuddy + self.fetch = fetch + self.sync = sync + self.export = export + self.status = status + self.list = list + self.clear = clear + } + + /// Validate that all required fields are present + public func validated() throws -> ValidatedBushelConfiguration { + guard let cloudKit = cloudKit else { + throw ConfigurationError("CloudKit configuration required", key: "cloudkit") + } + return ValidatedBushelConfiguration( + cloudKit: try cloudKit.validated(), + virtualBuddy: virtualBuddy, + fetch: fetch, + sync: sync, + export: export, + status: status, + list: list, + clear: clear + ) + } +} + +// MARK: - Validated Root Configuration + +/// Validated configuration with non-optional required fields +public struct ValidatedBushelConfiguration: Sendable { + public let cloudKit: ValidatedCloudKitConfiguration + public let virtualBuddy: VirtualBuddyConfiguration? + public let fetch: FetchConfiguration? + public let sync: SyncConfiguration? + public let export: ExportConfiguration? + public let status: StatusConfiguration? + public let list: ListConfiguration? + public let clear: ClearConfiguration? + + public init( + cloudKit: ValidatedCloudKitConfiguration, + virtualBuddy: VirtualBuddyConfiguration?, + fetch: FetchConfiguration?, + sync: SyncConfiguration?, + export: ExportConfiguration?, + status: StatusConfiguration?, + list: ListConfiguration?, + clear: ClearConfiguration? + ) { + self.cloudKit = cloudKit + self.virtualBuddy = virtualBuddy + self.fetch = fetch + self.sync = sync + self.export = export + self.status = status + self.list = list + self.clear = clear + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift new file mode 100644 index 00000000..e948a3b9 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift @@ -0,0 +1,150 @@ +// +// CloudKitConfiguration.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +public import MistKit + +// MARK: - CloudKit Configuration + +/// CloudKit Server-to-Server authentication configuration +public struct CloudKitConfiguration: Sendable { + public var containerID: String? + public var keyID: String? + public var privateKeyPath: String? + public var privateKey: String? // Raw PEM string for CI/CD + public var environment: String? // "development" or "production" + + public init( + containerID: String? = nil, + keyID: String? = nil, + privateKeyPath: String? = nil, + privateKey: String? = nil, + environment: String? = nil + ) { + self.containerID = containerID + self.keyID = keyID + self.privateKeyPath = privateKeyPath + self.privateKey = privateKey + self.environment = environment + } + + /// Validate that all required CloudKit fields are present + public func validated() throws -> ValidatedCloudKitConfiguration { + try ValidatedCloudKitConfiguration(from: self) + } +} + +/// Validated CloudKit configuration with non-optional fields +public struct ValidatedCloudKitConfiguration: Sendable { + public let containerID: String + public let keyID: String + public let privateKeyPath: String // Can be empty if privateKey is used + public let privateKey: String? // Optional (only one method required) + public let environment: MistKit.Environment + + public init(from config: CloudKitConfiguration) throws { + // Validate container ID + guard let containerID = config.containerID, !containerID.isEmpty else { + throw ConfigurationError( + "CloudKit container ID required. Set CLOUDKIT_CONTAINER_ID or use --cloudkit-container-id", + key: "cloudkit.container_id" + ) + } + + // Validate key ID + guard let keyID = config.keyID, !keyID.isEmpty else { + throw ConfigurationError( + "CloudKit key ID required. Set CLOUDKIT_KEY_ID or use --cloudkit-key-id", + key: "cloudkit.key_id" + ) + } + + // Validate at least ONE credential method is provided (NOT both required) + let trimmedPrivateKey = config.privateKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedPrivateKeyPath = + config.privateKeyPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + let hasPrivateKey = !trimmedPrivateKey.isEmpty + let hasPrivateKeyPath = !trimmedPrivateKeyPath.isEmpty + + guard hasPrivateKey || hasPrivateKeyPath else { + throw ConfigurationError( + "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided", + key: "cloudkit.private_key" + ) + } + + // Parse environment string to enum (case-insensitive for user convenience) + let environmentString = (config.environment ?? "development") + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let parsedEnvironment = MistKit.Environment(rawValue: environmentString) else { + throw ConfigurationError( + """ + Invalid CLOUDKIT_ENVIRONMENT: '\(config.environment ?? "")'. \ + Must be 'development' or 'production' + """, + key: "cloudkit.environment" + ) + } + + self.containerID = containerID + self.keyID = keyID + self.privateKeyPath = hasPrivateKeyPath ? trimmedPrivateKeyPath : "" + self.privateKey = hasPrivateKey ? trimmedPrivateKey : nil + self.environment = parsedEnvironment + } + + // Legacy initializer for backward compatibility (if needed by tests) + public init( + containerID: String, + keyID: String, + privateKeyPath: String, + privateKey: String? = nil, + environment: MistKit.Environment + ) { + self.containerID = containerID + self.keyID = keyID + self.privateKeyPath = privateKeyPath + self.privateKey = privateKey + self.environment = environment + } +} + +// MARK: - VirtualBuddy Configuration + +/// VirtualBuddy TSS API configuration +public struct VirtualBuddyConfiguration: Sendable { + public var apiKey: String? + + public init(apiKey: String? = nil) { + self.apiKey = apiKey + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CommandConfigurations.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CommandConfigurations.swift new file mode 100644 index 00000000..7b0c257d --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CommandConfigurations.swift @@ -0,0 +1,149 @@ +// +// CommandConfigurations.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Sync Configuration + +/// Sync command configuration +public struct SyncConfiguration: Sendable { + public var dryRun: Bool + public var restoreImagesOnly: Bool + public var xcodeOnly: Bool + public var swiftOnly: Bool + public var noBetas: Bool + public var noAppleWiki: Bool + public var verbose: Bool + public var force: Bool + public var minInterval: Int? + public var source: String? + public var jsonOutputFile: String? + + public init( + dryRun: Bool = false, + restoreImagesOnly: Bool = false, + xcodeOnly: Bool = false, + swiftOnly: Bool = false, + noBetas: Bool = false, + noAppleWiki: Bool = false, + verbose: Bool = false, + force: Bool = false, + minInterval: Int? = nil, + source: String? = nil, + jsonOutputFile: String? = nil + ) { + self.dryRun = dryRun + self.restoreImagesOnly = restoreImagesOnly + self.xcodeOnly = xcodeOnly + self.swiftOnly = swiftOnly + self.noBetas = noBetas + self.noAppleWiki = noAppleWiki + self.verbose = verbose + self.force = force + self.minInterval = minInterval + self.source = source + self.jsonOutputFile = jsonOutputFile + } +} + +// MARK: - Export Configuration + +/// Export command configuration +public struct ExportConfiguration: Sendable { + public var output: String? + public var pretty: Bool + public var signedOnly: Bool + public var noBetas: Bool + public var verbose: Bool + + public init( + output: String? = nil, + pretty: Bool = false, + signedOnly: Bool = false, + noBetas: Bool = false, + verbose: Bool = false + ) { + self.output = output + self.pretty = pretty + self.signedOnly = signedOnly + self.noBetas = noBetas + self.verbose = verbose + } +} + +// MARK: - Status Configuration + +/// Status command configuration +public struct StatusConfiguration: Sendable { + public var errorsOnly: Bool + public var detailed: Bool + + public init( + errorsOnly: Bool = false, + detailed: Bool = false + ) { + self.errorsOnly = errorsOnly + self.detailed = detailed + } +} + +// MARK: - List Configuration + +/// List command configuration +public struct ListConfiguration: Sendable { + public var restoreImages: Bool + public var xcodeVersions: Bool + public var swiftVersions: Bool + + public init( + restoreImages: Bool = false, + xcodeVersions: Bool = false, + swiftVersions: Bool = false + ) { + self.restoreImages = restoreImages + self.xcodeVersions = xcodeVersions + self.swiftVersions = swiftVersions + } +} + +// MARK: - Clear Configuration + +/// Clear command configuration +public struct ClearConfiguration: Sendable { + public var yes: Bool + public var verbose: Bool + + public init( + yes: Bool = false, + verbose: Bool = false + ) { + self.yes = yes + self.verbose = verbose + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift new file mode 100644 index 00000000..2fc5fc48 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift @@ -0,0 +1,157 @@ +// +// ConfigurationKeys.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ConfigKeyKit +import Foundation + +/// Configuration keys for reading from providers +internal enum ConfigurationKeys { + // MARK: - CloudKit Configuration + + /// CloudKit configuration keys + /// + /// Auto-generates environment variable names from the key path. + /// Example: "cloudkit.container_id" → ENV: CLOUDKIT_CONTAINER_ID + internal enum CloudKit { + internal static let containerID = ConfigKey( + "cloudkit.container_id", + default: "iCloud.com.brightdigit.Bushel" + ) + + internal static let keyID = OptionalConfigKey( + "cloudkit.key_id" + ) + + internal static let privateKeyPath = OptionalConfigKey( + "cloudkit.private_key_path" + ) + + internal static let privateKey = OptionalConfigKey( + "cloudkit.private_key" + ) + + internal static let environment = OptionalConfigKey( + "cloudkit.environment" + ) + } + + // MARK: - VirtualBuddy Configuration + + /// VirtualBuddy TSS API configuration keys + /// + /// Auto-generates ENV names (VIRTUALBUDDY_API_KEY). + internal enum VirtualBuddy { + internal static let apiKey = OptionalConfigKey( + "virtualbuddy.api_key" + ) + } + + // MARK: - Fetch Configuration + + /// Fetch throttling configuration keys + /// + /// Uses `bushelPrefixed:` to add BUSHEL_ prefix to all environment variables. + /// Example: "fetch.interval_global" → ENV: BUSHEL_FETCH_INTERVAL_GLOBAL + internal enum Fetch { + /// Generate per-source interval key dynamically + /// - Parameter source: Data source identifier (e.g., "appledb.dev") + /// - Returns: An OptionalConfigKey for the source-specific interval + internal static func intervalKey(for source: String) -> OptionalConfigKey { + let normalized = source.replacingOccurrences(of: ".", with: "_") + return OptionalConfigKey( + "fetch.interval.\(normalized)" + ) + } + } + + // MARK: - Sync Command Configuration + + /// Sync command configuration keys + /// + /// Uses `bushelPrefixed:` for BUSHEL_SYNC_* environment variables. + internal enum Sync { + internal static let dryRun = ConfigKey(bushelPrefixed: "sync.dry_run") + internal static let restoreImagesOnly = ConfigKey( + bushelPrefixed: "sync.restore_images_only" + ) + internal static let xcodeOnly = ConfigKey(bushelPrefixed: "sync.xcode_only") + internal static let swiftOnly = ConfigKey(bushelPrefixed: "sync.swift_only") + internal static let noBetas = ConfigKey(bushelPrefixed: "sync.no_betas") + internal static let noAppleWiki = ConfigKey(bushelPrefixed: "sync.no_apple_wiki") + internal static let verbose = ConfigKey(bushelPrefixed: "sync.verbose") + internal static let force = ConfigKey(bushelPrefixed: "sync.force") + internal static let minInterval = OptionalConfigKey(bushelPrefixed: "sync.min_interval") + internal static let source = OptionalConfigKey(bushelPrefixed: "sync.source") + internal static let jsonOutputFile = OptionalConfigKey(bushelPrefixed: "sync.json_output_file") + } + + // MARK: - Export Command Configuration + + /// Export command configuration keys + /// + /// Uses `bushelPrefixed:` for BUSHEL_EXPORT_* environment variables. + internal enum Export { + internal static let output = OptionalConfigKey(bushelPrefixed: "export.output") + internal static let pretty = ConfigKey(bushelPrefixed: "export.pretty") + internal static let signedOnly = ConfigKey(bushelPrefixed: "export.signed_only") + internal static let noBetas = ConfigKey(bushelPrefixed: "export.no_betas") + internal static let verbose = ConfigKey(bushelPrefixed: "export.verbose") + } + + // MARK: - Status Command Configuration + + /// Status command configuration keys + /// + /// Uses `bushelPrefixed:` for BUSHEL_STATUS_* environment variables. + internal enum Status { + internal static let errorsOnly = ConfigKey(bushelPrefixed: "status.errors_only") + internal static let detailed = ConfigKey(bushelPrefixed: "status.detailed") + } + + // MARK: - List Command Configuration + + /// List command configuration keys + /// + /// Uses `bushelPrefixed:` for BUSHEL_LIST_* environment variables. + internal enum List { + internal static let restoreImages = ConfigKey(bushelPrefixed: "list.restore_images") + internal static let xcodeVersions = ConfigKey(bushelPrefixed: "list.xcode_versions") + internal static let swiftVersions = ConfigKey(bushelPrefixed: "list.swift_versions") + } + + // MARK: - Clear Command Configuration + + /// Clear command configuration keys + /// + /// Uses `bushelPrefixed:` for BUSHEL_CLEAR_* environment variables. + internal enum Clear { + internal static let yes = ConfigKey(bushelPrefixed: "clear.yes") + internal static let verbose = ConfigKey(bushelPrefixed: "clear.verbose") + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift new file mode 100644 index 00000000..b64874ed --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift @@ -0,0 +1,140 @@ +// +// ConfigurationLoader+Loading.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +import Foundation + +// MARK: - Configuration Loading + +extension ConfigurationLoader { + /// Load the complete configuration from all providers + public func loadConfiguration() async throws -> BushelConfiguration { + // CloudKit configuration (automatic CLI → ENV → default fallback) + let cloudKit = CloudKitConfiguration( + containerID: read(ConfigurationKeys.CloudKit.containerID), + keyID: read(ConfigurationKeys.CloudKit.keyID), + privateKeyPath: read(ConfigurationKeys.CloudKit.privateKeyPath), + privateKey: read(ConfigurationKeys.CloudKit.privateKey), + // Default to development + environment: read(ConfigurationKeys.CloudKit.environment) ?? "development" + ) + + // VirtualBuddy configuration + let virtualBuddy = VirtualBuddyConfiguration( + apiKey: read(ConfigurationKeys.VirtualBuddy.apiKey) + ) + + // Fetch configuration: Start with BushelKit's environment loading, then override with CLI + var fetch = FetchConfiguration.loadFromEnvironment() + + // Override global interval if --min-interval provided + if let minInterval = read(ConfigurationKeys.Sync.minInterval) { + fetch = FetchConfiguration( + globalMinimumFetchInterval: TimeInterval(minInterval), + perSourceIntervals: fetch.perSourceIntervals, + useDefaults: true + ) + } + + // Override per-source intervals from CLI or ENV + var perSourceIntervals = fetch.perSourceIntervals + + for source in DataSource.allCases { + // Try CLI arg first (e.g., "fetch.interval.appledb_dev") + // Then try ENV var (e.g., "BUSHEL_FETCH_INTERVAL_APPLEDB_DEV") + let intervalKey = ConfigurationKeys.Fetch.intervalKey(for: source.rawValue) + if let interval = read(intervalKey) { + perSourceIntervals[source.rawValue] = interval + } + } + + // Rebuild fetch configuration with updated intervals if any were found + if !perSourceIntervals.isEmpty { + fetch = FetchConfiguration( + globalMinimumFetchInterval: fetch.globalMinimumFetchInterval, + perSourceIntervals: perSourceIntervals, + useDefaults: fetch.useDefaults + ) + } + + // Sync command configuration + let sync = SyncConfiguration( + dryRun: read(ConfigurationKeys.Sync.dryRun), + restoreImagesOnly: read(ConfigurationKeys.Sync.restoreImagesOnly), + xcodeOnly: read(ConfigurationKeys.Sync.xcodeOnly), + swiftOnly: read(ConfigurationKeys.Sync.swiftOnly), + noBetas: read(ConfigurationKeys.Sync.noBetas), + noAppleWiki: read(ConfigurationKeys.Sync.noAppleWiki), + verbose: read(ConfigurationKeys.Sync.verbose), + force: read(ConfigurationKeys.Sync.force), + minInterval: read(ConfigurationKeys.Sync.minInterval), + source: read(ConfigurationKeys.Sync.source), + jsonOutputFile: read(ConfigurationKeys.Sync.jsonOutputFile) + ) + + // Export command configuration + let export = ExportConfiguration( + output: read(ConfigurationKeys.Export.output), + pretty: read(ConfigurationKeys.Export.pretty), + signedOnly: read(ConfigurationKeys.Export.signedOnly), + noBetas: read(ConfigurationKeys.Export.noBetas), + verbose: read(ConfigurationKeys.Export.verbose) + ) + + // Status command configuration + let status = StatusConfiguration( + errorsOnly: read(ConfigurationKeys.Status.errorsOnly), + detailed: read(ConfigurationKeys.Status.detailed) + ) + + // List command configuration + let list = ListConfiguration( + restoreImages: read(ConfigurationKeys.List.restoreImages), + xcodeVersions: read(ConfigurationKeys.List.xcodeVersions), + swiftVersions: read(ConfigurationKeys.List.swiftVersions) + ) + + // Clear command configuration + let clear = ClearConfiguration( + yes: read(ConfigurationKeys.Clear.yes), + verbose: read(ConfigurationKeys.Clear.verbose) + ) + + return BushelConfiguration( + cloudKit: cloudKit, + virtualBuddy: virtualBuddy, + fetch: fetch, + sync: sync, + export: export, + status: status, + list: list, + clear: clear + ) + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift new file mode 100644 index 00000000..566f7951 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift @@ -0,0 +1,176 @@ +// +// ConfigurationLoader.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ConfigKeyKit +import Configuration +import Foundation + +/// Actor responsible for loading configuration from CLI arguments and environment variables +public actor ConfigurationLoader { + private let configReader: ConfigReader + + /// Initialize the configuration loader with command-line and environment providers + public init() { + var providers: [any ConfigProvider] = [] + + // Priority 1: Command-line arguments (automatically parses all --key value and --flag arguments) + providers.append( + CommandLineArgumentsProvider( + secretsSpecifier: .specific([ + "--cloudkit-key-id", + "--cloudkit-private-key-path", + "--virtualbuddy-api-key", + ]) + ) + ) + + // Priority 2: Environment variables + providers.append(EnvironmentVariablesProvider()) + + self.configReader = ConfigReader(providers: providers) + } + + #if DEBUG + /// Test-only initializer that accepts a pre-configured ConfigReader + /// + /// This allows tests to inject controlled configuration sources without + /// modifying process-global state (environment variables). + /// + /// - Parameter configReader: Pre-configured ConfigReader for testing + internal init(configReader: ConfigReader) { + self.configReader = configReader + } + #endif + + // MARK: - Helper Methods + + /// Read a string value from configuration + internal func readString(forKey key: String) -> String? { + configReader.string(forKey: ConfigKey(key)) + } + + /// Read an integer value from configuration + internal func readInt(forKey key: String) -> Int? { + guard let stringValue = configReader.string(forKey: ConfigKey(key)) else { + return nil + } + return Int(stringValue) + } + + /// Read a double value from configuration + internal func readDouble(forKey key: String) -> Double? { + guard let stringValue = configReader.string(forKey: ConfigKey(key)) else { + return nil + } + return Double(stringValue) + } + + // MARK: - Generic Helper Methods for ConfigKey (with defaults) + + /// Read a string value with automatic CLI → ENV → default fallback + /// Returns non-optional since ConfigKey has a required default + internal func read(_ key: ConfigKeyKit.ConfigKey) -> String { + for source in ConfigKeySource.allCases { + guard let keyString = key.key(for: source) else { continue } + if let value = readString(forKey: keyString) { + return value + } + } + return key.defaultValue // Non-optional! + } + + /// Read a boolean value with enhanced ENV variable parsing + /// + /// Returns non-optional since ConfigKey has a required default. + /// + /// Boolean parsing rules: + /// - CLI: Flag presence indicates true (e.g., --verbose) + /// - ENV: Accepts "true", "1", "yes" (case-insensitive) + /// - Empty string in ENV is treated as absent (falls back to default) + /// + /// - Parameter key: Configuration key with boolean type + /// - Returns: Boolean value from CLI/ENV or the key's default + internal func read(_ key: ConfigKeyKit.ConfigKey) -> Bool { + // Try CLI first (presence-based for flags) + if let cliKey = key.key(for: .commandLine), + configReader.string(forKey: ConfigKey(cliKey)) != nil + { + return true + } + + // Try ENV (may have string value like VERBOSE=true) + if let envKey = key.key(for: .environment), + let envValue = configReader.string(forKey: ConfigKey(envKey)) + { + let lowercased = envValue.lowercased().trimmingCharacters(in: .whitespaces) + return lowercased == "true" || lowercased == "1" || lowercased == "yes" + } + + // Use default value (non-optional) + return key.defaultValue + } + + // MARK: - Generic Helper Methods for OptionalConfigKey (without defaults) + + /// Read a string value with automatic CLI → ENV fallback + /// Returns optional since OptionalConfigKey has no default + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> String? { + for source in ConfigKeySource.allCases { + guard let keyString = key.key(for: source) else { continue } + if let value = readString(forKey: keyString) { + return value + } + } + return nil // No default available + } + + /// Read an integer value with automatic CLI → ENV fallback + /// Returns optional since OptionalConfigKey has no default + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Int? { + for source in ConfigKeySource.allCases { + guard let keyString = key.key(for: source) else { continue } + if let value = readInt(forKey: keyString) { + return value + } + } + return nil // No default available + } + + /// Read a double value with automatic CLI → ENV fallback + /// Returns optional since OptionalConfigKey has no default + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Double? { + for source in ConfigKeySource.allCases { + guard let keyString = key.key(for: source) else { continue } + if let value = readDouble(forKey: keyString) { + return value + } + } + return nil // No default available + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBEntry.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBEntry.swift new file mode 100644 index 00000000..edddd6cd --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBEntry.swift @@ -0,0 +1,50 @@ +// +// AppleDBEntry.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Represents a single macOS build entry from AppleDB +internal struct AppleDBEntry: Codable { + internal enum CodingKeys: String, CodingKey { + case version, build, released + case beta, rc + case `internal` = "internal" + case deviceMap, signed, sources + } + + internal let version: String + internal let build: String? // Some entries may not have a build number + internal let released: String // ISO date or empty string + internal let beta: Bool? + internal let rc: Bool? + internal let `internal`: Bool? + internal let deviceMap: [String] + internal let signed: SignedStatus + internal let sources: [AppleDBSource]? +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBFetcher.swift new file mode 100644 index 00000000..6d1d32fa --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBFetcher.swift @@ -0,0 +1,219 @@ +// +// AppleDBFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import BushelLogging +import BushelUtilities +import Foundation +import Logging + +#if canImport(FelinePineSwift) + import FelinePineSwift +#endif + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Fetcher for macOS restore images using AppleDB API +public struct AppleDBFetcher: DataSourceFetcher, Sendable { + public typealias Record = [RestoreImageRecord] + + // MARK: - Type Properties + + // swiftlint:disable:next force_unwrapping + private static let githubCommitsURL = URL( + string: + "https://api.github.com/repos/littlebyteorg/appledb/commits?path=osFiles/macOS&per_page=1" + )! + + // swiftlint:disable:next force_unwrapping + private static let appleDBURL = URL(string: "https://api.appledb.dev/ios/macOS/main.json")! + + // MARK: - Instance Properties + + private let deviceIdentifier = "VirtualMac2,1" + + // MARK: - Initializers + + public init() {} + + // MARK: - Private Type Methods + + /// Fetch the last commit date for macOS data from GitHub API + private static func fetchGitHubLastCommitDate() async -> Date? { + do { + let (data, _) = try await URLSession.shared.data(from: githubCommitsURL) + + let commits = try JSONDecoder().decode([GitHubCommitsResponse].self, from: data) + + guard let firstCommit = commits.first else { + Self.logger.warning( + "No commits found in AppleDB GitHub repository" + ) + return nil + } + + // Parse ISO 8601 date + let isoFormatter = ISO8601DateFormatter() + guard let date = isoFormatter.date(from: firstCommit.commit.committer.date) else { + Self.logger.warning( + "Failed to parse commit date: \(firstCommit.commit.committer.date)" + ) + return nil + } + + Self.logger.debug( + "AppleDB macOS data last updated: \(date) (commit: \(firstCommit.sha.prefix(7)))" + ) + return date + } catch { + Self.logger.warning( + "Failed to fetch GitHub commit date for AppleDB: \(error)" + ) + // Fallback to HTTP Last-Modified header + return await URLSession.shared.fetchLastModified(from: appleDBURL) + } + } + + /// Fetch macOS data from AppleDB API + private static func fetchAppleDBData() async throws -> [AppleDBEntry] { + Self.logger.debug("Fetching AppleDB data from \(appleDBURL)") + + let (data, _) = try await URLSession.shared.data(from: appleDBURL) + + let entries = try JSONDecoder().decode([AppleDBEntry].self, from: data) + + Self.logger.debug( + "Fetched \(entries.count) total entries from AppleDB" + ) + + return entries + } + + // MARK: - Public Methods + + /// Fetch all VirtualMac2,1 restore images from AppleDB + public func fetch() async throws -> [RestoreImageRecord] { + // Fetch when macOS data was last updated using GitHub API + let sourceUpdatedAt = await Self.fetchGitHubLastCommitDate() + + // Fetch AppleDB data + let entries = try await Self.fetchAppleDBData() + + // Filter for VirtualMac2,1 and map to RestoreImageRecord + return + entries + .filter { $0.deviceMap.contains(deviceIdentifier) } + .compactMap { entry in + mapToRestoreImage( + entry: entry, + sourceUpdatedAt: sourceUpdatedAt, + deviceIdentifier: deviceIdentifier + ) + } + } + + // MARK: - Private Instance Methods + + /// Map an AppleDB entry to RestoreImageRecord + private func mapToRestoreImage( + entry: AppleDBEntry, + sourceUpdatedAt: Date?, + deviceIdentifier: String + ) -> RestoreImageRecord? { + // Skip entries without a build number (required for unique identification) + guard let build = entry.build else { + Self.logger.debug( + "Skipping AppleDB entry without build number: \(entry.version)" + ) + return nil + } + + // Determine if signed for VirtualMac2,1 + let isSigned = entry.signed.isSigned(for: deviceIdentifier) + + // Determine if prerelease + let isPrerelease = entry.beta == true || entry.rc == true || entry.internal == true + + // Parse release date if available + let releaseDate: Date? + if !entry.released.isEmpty { + let isoFormatter = ISO8601DateFormatter() + releaseDate = isoFormatter.date(from: entry.released) + } else { + releaseDate = nil + } + + // Find IPSW source + guard let ipswSource = entry.sources?.first(where: { $0.type == "ipsw" }) else { + Self.logger.debug( + "No IPSW source found for build \(build)" + ) + return nil + } + + // Get preferred or first active link + guard let link = ipswSource.links?.first(where: { $0.preferred == true || $0.active == true }) + else { + Self.logger.debug( + "No active download link found for build \(build)" + ) + return nil + } + + // Convert link.url String to URL + guard let downloadURL = URL(string: link.url) else { + Self.logger.debug( + "Invalid download URL for build \(build): \(link.url)" + ) + return nil + } + + return RestoreImageRecord( + version: entry.version, + buildNumber: build, + releaseDate: releaseDate ?? Date(), // Fallback to current date + downloadURL: downloadURL, + fileSize: ipswSource.size ?? 0, + sha256Hash: ipswSource.hashes?.sha2256 ?? "", + sha1Hash: ipswSource.hashes?.sha1 ?? "", + isSigned: isSigned, + isPrerelease: isPrerelease, + source: "appledb.dev", + notes: "Device-specific signing status from AppleDB", + sourceUpdatedAt: sourceUpdatedAt + ) + } +} + +// MARK: - Loggable Conformance +extension AppleDBFetcher: Loggable { + public static let loggingCategory: BushelLogging.Category = .hub +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBHashes.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBHashes.swift new file mode 100644 index 00000000..5721fe36 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBHashes.swift @@ -0,0 +1,41 @@ +// +// AppleDBHashes.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Represents file hashes for verification +internal struct AppleDBHashes: Codable { + internal enum CodingKeys: String, CodingKey { + case sha1 + case sha2256 = "sha2-256" + } + + internal let sha1: String? + internal let sha2256: String? // JSON key is "sha2-256" +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBLink.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBLink.swift new file mode 100644 index 00000000..5f47a6db --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBLink.swift @@ -0,0 +1,37 @@ +// +// AppleDBLink.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Represents a download link for a source +internal struct AppleDBLink: Codable { + internal let url: String + internal let preferred: Bool? + internal let active: Bool? +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBSource.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBSource.swift new file mode 100644 index 00000000..399f0bd9 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBSource.swift @@ -0,0 +1,40 @@ +// +// AppleDBSource.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Represents an installation source (IPSW, OTA, or IA) +internal struct AppleDBSource: Codable { + internal let type: String // "ipsw", "ota", "ia" + internal let deviceMap: [String] + internal let links: [AppleDBLink]? + internal let hashes: AppleDBHashes? + internal let size: Int? + internal let prerequisiteBuild: String? +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommit.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommit.swift new file mode 100644 index 00000000..38c3fb63 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommit.swift @@ -0,0 +1,36 @@ +// +// GitHubCommit.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Represents a commit in GitHub API response +internal struct GitHubCommit: Codable { + internal let committer: GitHubCommitter + internal let message: String +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitsResponse.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitsResponse.swift new file mode 100644 index 00000000..56954e42 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitsResponse.swift @@ -0,0 +1,36 @@ +// +// GitHubCommitsResponse.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Response from GitHub API for commits +internal struct GitHubCommitsResponse: Codable { + internal let sha: String + internal let commit: GitHubCommit +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitter.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitter.swift new file mode 100644 index 00000000..aee3843d --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitter.swift @@ -0,0 +1,35 @@ +// +// GitHubCommitter.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Represents a committer in GitHub API response +internal struct GitHubCommitter: Codable { + internal let date: String // ISO 8601 format +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/SignedStatus.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/SignedStatus.swift new file mode 100644 index 00000000..aebc8bbd --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/SignedStatus.swift @@ -0,0 +1,83 @@ +// +// SignedStatus.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Represents the signing status for a build +/// Can be: array of device IDs, boolean true (all signed), or empty array (none signed) +internal enum SignedStatus: Codable { + case devices([String]) // Array of signed device IDs + case all(Bool) // true = all devices signed + case none // Empty array = not signed + + internal init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + // Try decoding as array first + if let devices = try? container.decode([String].self) { + if devices.isEmpty { + self = .none + } else { + self = .devices(devices) + } + } + // Then try boolean + else if let allSigned = try? container.decode(Bool.self) { + self = .all(allSigned) + } + // Default to none if decoding fails + else { + self = .none + } + } + + internal func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .devices(let devices): + try container.encode(devices) + case .all(let value): + try container.encode(value) + case .none: + try container.encode([String]()) + } + } + + /// Check if a specific device identifier is signed + internal func isSigned(for deviceIdentifier: String) -> Bool { + switch self { + case .devices(let devices): + return devices.contains(deviceIdentifier) + case .all(true): + return true + case .all(false), .none: + return false + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Deduplication.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Deduplication.swift new file mode 100644 index 00000000..70ca5357 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Deduplication.swift @@ -0,0 +1,194 @@ +// +// DataSourcePipeline+Deduplication.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +import Foundation + +// MARK: - Deduplication +extension DataSourcePipeline { + /// Deduplicate restore images by build number, keeping the most complete record + internal func deduplicateRestoreImages(_ images: [RestoreImageRecord]) -> [RestoreImageRecord] { + var uniqueImages: [String: RestoreImageRecord] = [:] + + for image in images { + let key = image.buildNumber + + if let existing = uniqueImages[key] { + // Keep the record with more complete data + uniqueImages[key] = mergeRestoreImages(existing, image) + } else { + uniqueImages[key] = image + } + } + + return Array(uniqueImages.values).sorted { $0.releaseDate > $1.releaseDate } + } + + /// Merge two restore image records, preferring non-empty values + /// + /// This method handles backfilling missing data from different sources: + /// - SHA-256 hashes from AppleDB fill in empty values from ipsw.me + /// - File sizes and SHA-1 hashes are similarly backfilled + /// - Signing status follows MESU authoritative rules + internal func mergeRestoreImages( + _ first: RestoreImageRecord, + _ second: RestoreImageRecord + ) -> RestoreImageRecord { + var merged = first + + // Backfill missing hashes and file size from second record + merged.sha256Hash = backfillValue(first: first.sha256Hash, second: second.sha256Hash) + merged.sha1Hash = backfillValue(first: first.sha1Hash, second: second.sha1Hash) + merged.fileSize = backfillFileSize(first: first.fileSize, second: second.fileSize) + + // Merge isSigned using priority rules + merged.isSigned = mergeIsSignedStatus(first: first, second: second) + + // Combine notes + merged.notes = combineNotes(first: first.notes, second: second.notes) + + return merged + } + + private func backfillValue(first: String, second: String) -> String { + if !second.isEmpty && first.isEmpty { + return second + } + return first + } + + private func backfillFileSize(first: Int, second: Int) -> Int { + if second > 0 && first == 0 { + return second + } + return first + } + + private func mergeIsSignedStatus( + first: RestoreImageRecord, + second: RestoreImageRecord + ) -> Bool? { + // Define authoritative sources for signing status + let authoritativeSources: Set = ["mesu.apple.com", "tss.virtualbuddy.app"] + + // Priority 1: Authoritative sources (MESU or VirtualBuddy) + if authoritativeSources.contains(first.source), let signed = first.isSigned { + return signed + } + if authoritativeSources.contains(second.source), let signed = second.isSigned { + return signed + } + + // Priority 2: Most recent update timestamp + return mergeIsSignedByTimestamp( + firstSigned: first.isSigned, + firstDate: first.sourceUpdatedAt, + secondSigned: second.isSigned, + secondDate: second.sourceUpdatedAt + ) + } + + private func mergeIsSignedByTimestamp( + firstSigned: Bool?, + firstDate: Date?, + secondSigned: Bool?, + secondDate: Date? + ) -> Bool? { + // Both have dates - use the more recent one + if let firstTimestamp = firstDate, let secondTimestamp = secondDate { + if secondTimestamp > firstTimestamp { + return secondSigned ?? firstSigned + } else { + return firstSigned ?? secondSigned + } + } + + // Only second has date + if secondDate != nil { + return secondSigned ?? firstSigned + } + + // Only first has date + if firstDate != nil { + return firstSigned ?? secondSigned + } + + // No dates - handle conflicting values + return resolveConflictingSignedStatus(first: firstSigned, second: secondSigned) + } + + private func resolveConflictingSignedStatus(first: Bool?, second: Bool?) -> Bool? { + guard let firstValue = first, let secondValue = second else { + return second ?? first + } + + // Both have values - prefer false when they disagree + return firstValue == secondValue ? firstValue : false + } + + private func combineNotes(first: String?, second: String?) -> String? { + guard let secondNotes = second, !secondNotes.isEmpty else { + return first + } + + if let firstNotes = first, !firstNotes.isEmpty { + return "\(firstNotes); \(secondNotes)" + } + + return secondNotes + } + + /// Deduplicate Xcode versions by build number + internal func deduplicateXcodeVersions(_ versions: [XcodeVersionRecord]) -> [XcodeVersionRecord] { + var uniqueVersions: [String: XcodeVersionRecord] = [:] + + for version in versions { + let key = version.buildNumber + if uniqueVersions[key] == nil { + uniqueVersions[key] = version + } + } + + return Array(uniqueVersions.values).sorted { $0.releaseDate > $1.releaseDate } + } + + /// Deduplicate Swift versions by version number + internal func deduplicateSwiftVersions(_ versions: [SwiftVersionRecord]) -> [SwiftVersionRecord] { + var uniqueVersions: [String: SwiftVersionRecord] = [:] + + for version in versions { + let key = version.version + if uniqueVersions[key] == nil { + uniqueVersions[key] = version + } + } + + return Array(uniqueVersions.values).sorted { $0.releaseDate > $1.releaseDate } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Fetchers.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Fetchers.swift new file mode 100644 index 00000000..90b3f797 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Fetchers.swift @@ -0,0 +1,233 @@ +// +// DataSourcePipeline+Fetchers.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +import Foundation + +// MARK: - Private Fetching Methods +extension DataSourcePipeline { + internal func fetchRestoreImages(options: Options) async throws -> [RestoreImageRecord] { + guard options.includeRestoreImages else { + return [] + } + + var allImages: [RestoreImageRecord] = [] + + allImages.append(contentsOf: try await fetchIPSWImages(options: options)) + allImages.append(contentsOf: try await fetchMESUImages(options: options)) + allImages.append(contentsOf: try await fetchAppleDBImages(options: options)) + allImages.append(contentsOf: try await fetchMrMacintoshImages(options: options)) + allImages.append(contentsOf: try await fetchTheAppleWikiImages(options: options)) + + allImages = try await enrichWithVirtualBuddy(allImages, options: options) + + // Deduplicate by build number (keep first occurrence) + let preDedupeCount = allImages.count + let deduped = deduplicateRestoreImages(allImages) + ConsoleOutput.print(" 📦 Deduplicated: \(preDedupeCount) → \(deduped.count) images") + return deduped + } + + private func fetchIPSWImages(options: Options) async throws -> [RestoreImageRecord] { + do { + let images = try await fetchWithMetadata( + source: "ipsw.me", + recordType: "RestoreImage", + options: options + ) { + try await IPSWFetcher().fetch() + } + if !images.isEmpty { + print(" ✓ ipsw.me: \(images.count) images") + } + return images + } catch { + print(" ⚠️ ipsw.me failed: \(error)") + throw error + } + } + + private func fetchMESUImages(options: Options) async throws -> [RestoreImageRecord] { + do { + let images = try await fetchWithMetadata( + source: "mesu.apple.com", + recordType: "RestoreImage", + options: options + ) { + if let image = try await MESUFetcher().fetch() { + return [image] + } else { + return [] + } + } + if !images.isEmpty { + print(" ✓ MESU: \(images.count) image") + } + return images + } catch { + print(" ⚠️ MESU failed: \(error)") + throw error + } + } + + private func fetchAppleDBImages(options: Options) async throws -> [RestoreImageRecord] { + guard options.includeAppleDB else { + return [] + } + + do { + let images = try await fetchWithMetadata( + source: "appledb.dev", + recordType: "RestoreImage", + options: options + ) { + try await AppleDBFetcher().fetch() + } + if !images.isEmpty { + print(" ✓ AppleDB: \(images.count) images") + } + return images + } catch { + print(" ⚠️ AppleDB failed: \(error)") + // Don't throw - continue with other sources + return [] + } + } + + private func fetchMrMacintoshImages(options: Options) async throws -> [RestoreImageRecord] { + guard options.includeBetaReleases else { + return [] + } + + do { + let images = try await fetchWithMetadata( + source: "mrmacintosh.com", + recordType: "RestoreImage", + options: options + ) { + try await MrMacintoshFetcher().fetch() + } + if !images.isEmpty { + print(" ✓ Mr. Macintosh: \(images.count) images") + } + return images + } catch { + print(" ⚠️ Mr. Macintosh failed: \(error)") + throw error + } + } + + private func fetchTheAppleWikiImages(options: Options) async throws -> [RestoreImageRecord] { + guard options.includeTheAppleWiki else { + return [] + } + + do { + let images = try await fetchWithMetadata( + source: "theapplewiki.com", + recordType: "RestoreImage", + options: options + ) { + try await TheAppleWikiFetcher().fetch() + } + if !images.isEmpty { + print(" ✓ TheAppleWiki: \(images.count) images") + } + return images + } catch { + print(" ⚠️ TheAppleWiki failed: \(error)") + throw error + } + } + + private func enrichWithVirtualBuddy( + _ images: [RestoreImageRecord], + options: Options + ) async throws -> [RestoreImageRecord] { + guard options.includeVirtualBuddy else { + return images + } + + guard let fetcher = VirtualBuddyFetcher() else { + print(" ⚠️ VirtualBuddy: No API key found (set VIRTUALBUDDY_API_KEY)") + return images + } + + do { + let enrichableCount = images.filter { $0.downloadURL.scheme != "file" }.count + let enrichedImages = try await fetcher.fetch(existingImages: images) + print(" ✓ VirtualBuddy: Enriched \(enrichableCount) images with signing status") + return enrichedImages + } catch { + print(" ⚠️ VirtualBuddy failed: \(error)") + // Don't throw - continue with original data + return images + } + } + + internal func fetchXcodeVersions(options: Options) async throws -> [XcodeVersionRecord] { + guard options.includeXcodeVersions else { + return [] + } + + let versions = try await fetchWithMetadata( + source: "xcodereleases.com", + recordType: "XcodeVersion", + options: options + ) { + try await XcodeReleasesFetcher().fetch() + } + + if !versions.isEmpty { + print(" ✓ xcodereleases.com: \(versions.count) versions") + } + + return deduplicateXcodeVersions(versions) + } + + internal func fetchSwiftVersions(options: Options) async throws -> [SwiftVersionRecord] { + guard options.includeSwiftVersions else { + return [] + } + + let versions = try await fetchWithMetadata( + source: "swiftversion.net", + recordType: "SwiftVersion", + options: options + ) { + try await SwiftVersionFetcher().fetch() + } + + if !versions.isEmpty { + print(" ✓ swiftversion.net: \(versions.count) versions") + } + + return deduplicateSwiftVersions(versions) + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+ReferenceResolution.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+ReferenceResolution.swift new file mode 100644 index 00000000..d3d05627 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+ReferenceResolution.swift @@ -0,0 +1,94 @@ +// +// DataSourcePipeline+ReferenceResolution.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation + +// MARK: - Reference Resolution +extension DataSourcePipeline { + /// Resolve XcodeVersion → RestoreImage references by mapping version strings to record names + /// + /// Parses the temporary REQUIRES field in notes and matches it to RestoreImage versions + internal func resolveXcodeVersionReferences( + _ versions: [XcodeVersionRecord], + restoreImages: [RestoreImageRecord] + ) -> [XcodeVersionRecord] { + // Build lookup table: version → RestoreImage recordName + var versionLookup: [String: String] = [:] + for image in restoreImages { + // Support multiple version formats: "14.2.1", "14.2", "14" + let version = image.version + versionLookup[version] = image.recordName + + // Also add short versions for matching (e.g., "14.2.1" → "14.2") + let components = version.split(separator: ".") + if components.count > 1 { + let shortVersion = components.prefix(2).joined(separator: ".") + versionLookup[shortVersion] = image.recordName + } + } + + return versions.map { version in + var resolved = version + + // Parse notes field to extract requires string + guard let notes = version.notes else { + return resolved + } + + let parts = notes.split(separator: "|") + var requiresString: String? + var notesURL: String? + + for part in parts { + if part.hasPrefix("REQUIRES:") { + requiresString = String(part.dropFirst("REQUIRES:".count)) + } else if part.hasPrefix("NOTES_URL:") { + notesURL = String(part.dropFirst("NOTES_URL:".count)) + } + } + + // Try to extract version number from requires (e.g., "macOS 14.2" → "14.2") + if let requires = requiresString { + // Match version patterns like "14.2", "14.2.1", etc. + let versionPattern = #/(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)/# + if let match = requires.firstMatch(of: versionPattern) { + let extractedVersion = String(match.1) + if let recordName = versionLookup[extractedVersion] { + resolved.minimumMacOS = recordName + } + } + } + + // Restore clean notes field + resolved.notes = notesURL + + return resolved + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline.swift new file mode 100644 index 00000000..021cae83 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline.swift @@ -0,0 +1,205 @@ +// +// DataSourcePipeline.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +import BushelLogging +import Foundation + +/// Orchestrates fetching data from all sources with deduplication and relationship resolution +public struct DataSourcePipeline: Sendable { + // MARK: - Configuration + + public struct Options: Sendable { + public var includeRestoreImages: Bool = true + public var includeXcodeVersions: Bool = true + public var includeSwiftVersions: Bool = true + public var includeBetaReleases: Bool = true + public var includeAppleDB: Bool = true + public var includeTheAppleWiki: Bool = true + public var includeVirtualBuddy: Bool = true + public var force: Bool = false + public var specificSource: String? + + public init( + includeRestoreImages: Bool = true, + includeXcodeVersions: Bool = true, + includeSwiftVersions: Bool = true, + includeBetaReleases: Bool = true, + includeAppleDB: Bool = true, + includeTheAppleWiki: Bool = true, + includeVirtualBuddy: Bool = true, + force: Bool = false, + specificSource: String? = nil + ) { + self.includeRestoreImages = includeRestoreImages + self.includeXcodeVersions = includeXcodeVersions + self.includeSwiftVersions = includeSwiftVersions + self.includeBetaReleases = includeBetaReleases + self.includeAppleDB = includeAppleDB + self.includeTheAppleWiki = includeTheAppleWiki + self.includeVirtualBuddy = includeVirtualBuddy + self.force = force + self.specificSource = specificSource + } + } + + // MARK: - Results + + public struct FetchResult: Sendable { + public var restoreImages: [RestoreImageRecord] + public var xcodeVersions: [XcodeVersionRecord] + public var swiftVersions: [SwiftVersionRecord] + + public init( + restoreImages: [RestoreImageRecord], + xcodeVersions: [XcodeVersionRecord], + swiftVersions: [SwiftVersionRecord] + ) { + self.restoreImages = restoreImages + self.xcodeVersions = xcodeVersions + self.swiftVersions = swiftVersions + } + } + + // MARK: - Dependencies + + internal let configuration: FetchConfiguration + + // MARK: - Initialization + + public init( + configuration: FetchConfiguration = FetchConfiguration.loadFromEnvironment() + ) { + self.configuration = configuration + } + + // MARK: - Public API + + /// Fetch all data from configured sources + public func fetch(options: Options = Options()) async throws -> FetchResult { + var restoreImages: [RestoreImageRecord] = [] + var xcodeVersions: [XcodeVersionRecord] = [] + var swiftVersions: [SwiftVersionRecord] = [] + + do { + restoreImages = try await fetchRestoreImages(options: options) + } catch { + print("⚠️ Restore images fetch failed: \(error)") + throw error + } + + do { + xcodeVersions = try await fetchXcodeVersions(options: options) + // Resolve XcodeVersion → RestoreImage references now that we have both datasets + xcodeVersions = resolveXcodeVersionReferences(xcodeVersions, restoreImages: restoreImages) + } catch { + print("⚠️ Xcode versions fetch failed: \(error)") + throw error + } + + do { + swiftVersions = try await fetchSwiftVersions(options: options) + } catch { + print("⚠️ Swift versions fetch failed: \(error)") + throw error + } + + return FetchResult( + restoreImages: restoreImages, + xcodeVersions: xcodeVersions, + swiftVersions: swiftVersions + ) + } + + // MARK: - Metadata Tracking + + /// Check if a source should be fetched based on throttling rules + private func shouldFetch( + source _: String, + recordType _: String, + force: Bool + ) async -> (shouldFetch: Bool, metadata: DataSourceMetadata?) { + // If force flag is set, always fetch + guard !force else { + return (true, nil) + } + + // No CloudKit service in BushelCloudData - always fetch + // CloudKit metadata checking will be re-added in Phase 4 + return (true, nil) + } + + /// Wrap a fetch operation with metadata tracking + internal func fetchWithMetadata( + source: String, + recordType: String, + options: Options, + fetcher: () async throws -> [T] + ) async throws -> [T] { + // Check if we should skip this source based on --source flag + if let specificSource = options.specificSource, specificSource != source { + print(" ⏭️ Skipping \(source) (--source=\(specificSource))") + return [] + } + + // Check throttling + let (shouldFetch, existingMetadata) = await shouldFetch( + source: source, + recordType: recordType, + force: options.force + ) + + if !shouldFetch { + if let metadata = existingMetadata { + let timeSinceLastFetch = Date().timeIntervalSince(metadata.lastFetchedAt) + let minInterval = configuration.minimumInterval(for: source) ?? 0 + let timeRemaining = minInterval - timeSinceLastFetch + let message = + "⏰ Skipping \(source) " + "(last fetched \(Int(timeSinceLastFetch / 60))m ago, " + + "wait \(Int(timeRemaining / 60))m)" + print(" \(message)") + } + return [] + } + + do { + let results = try await fetcher() + + // Metadata sync disabled in BushelCloudData (no CloudKit dependency) + // Will be re-enabled in Phase 4 when using BushelKit + + return results + } catch { + // Metadata sync disabled in BushelCloudData (no CloudKit dependency) + // Will be re-enabled in Phase 4 when using BushelKit + + throw error + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/IPSWFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/IPSWFetcher.swift new file mode 100644 index 00000000..85f3d656 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/IPSWFetcher.swift @@ -0,0 +1,102 @@ +// +// IPSWFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +import BushelUtilities +import Foundation +import IPSWDownloads +import OpenAPIURLSession +import OSVer + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Fetcher for macOS restore images using the IPSWDownloads package +internal struct IPSWFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [RestoreImageRecord] + /// Static base URL for IPSW API + private static let ipswBaseURL: URL = { + guard let url = URL(string: "https://api.ipsw.me/v4/device/VirtualMac2,1?type=ipsw") else { + fatalError("Invalid static URL for IPSW API - this should never happen") + } + return url + }() + + /// Fetch all VirtualMac2,1 restore images from ipsw.me + internal func fetch() async throws -> [RestoreImageRecord] { + // Fetch Last-Modified header to know when ipsw.me data was updated + let ipswURL = Self.ipswBaseURL + #if canImport(FoundationNetworking) + // Use FoundationNetworking.URLSession directly on Linux + let lastModified = await FoundationNetworking.URLSession.shared.fetchLastModified( + from: ipswURL + ) + #else + let lastModified = await URLSession.shared.fetchLastModified(from: ipswURL) + #endif + + // Create IPSWDownloads client with URLSession transport + let client = IPSWDownloads( + transport: URLSessionTransport() + ) + + // Fetch device firmware data for VirtualMac2,1 (macOS virtual machines) + let device = try await client.device( + withIdentifier: "VirtualMac2,1", + type: .ipsw + ) + + return device.firmwares.map { firmware in + RestoreImageRecord( + version: firmware.version.description, // OSVer -> String + buildNumber: firmware.buildid, + releaseDate: firmware.releasedate, + downloadURL: firmware.url, + fileSize: firmware.filesize, + sha256Hash: "", // Not provided by ipsw.me; backfilled from AppleDB during merge + sha1Hash: firmware.sha1sum?.hexString ?? "", + isSigned: firmware.signed, + isPrerelease: false, // ipsw.me doesn't include beta releases + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: lastModified // When ipsw.me last updated their database + ) + } + } +} + +// MARK: - Data Extension + +extension Data { + /// Convert Data to hexadecimal string + fileprivate var hexString: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MESUFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MESUFetcher.swift new file mode 100644 index 00000000..2717d45b --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MESUFetcher.swift @@ -0,0 +1,124 @@ +// +// MESUFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +import BushelUtilities +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Fetcher for Apple MESU (Mobile Equipment Software Update) manifest +/// Used for freshness detection of the latest signed restore image +public struct MESUFetcher: DataSourceFetcher, Sendable { + public typealias Record = RestoreImageRecord? + + // MARK: - Error Types + + internal enum FetchError: Error { + case invalidURL + case parsingFailed + } + + // MARK: - Initializers + + public init() {} + + // MARK: - Public Methods + + /// Fetch the latest signed restore image from Apple's MESU service + public func fetch() async throws -> RestoreImageRecord? { + let urlString = + "https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml" + guard let url = URL(string: urlString) else { + throw FetchError.invalidURL + } + + // Fetch Last-Modified header to know when MESU was last updated + let lastModified = await URLSession.shared.fetchLastModified(from: url) + + let (data, _) = try await URLSession.shared.data(from: url) + + // Parse as property list (plist) + guard + let plist = try PropertyListSerialization.propertyList(from: data, format: nil) + as? [String: Any] + else { + throw FetchError.parsingFailed + } + + // Navigate to the firmware data + // Structure: MobileDeviceSoftwareVersionsByVersion -> "1" -> + // MobileDeviceSoftwareVersions -> VirtualMac2,1 -> BuildVersion -> Restore + guard let versionsByVersion = plist["MobileDeviceSoftwareVersionsByVersion"] as? [String: Any], + let version1 = versionsByVersion["1"] as? [String: Any], + let softwareVersions = version1["MobileDeviceSoftwareVersions"] as? [String: Any], + let virtualMac = softwareVersions["VirtualMac2,1"] as? [String: Any] + else { + return nil + } + + // Find the first available build (should be the latest signed) + for (buildVersion, buildInfo) in virtualMac { + guard let buildInfo = buildInfo as? [String: Any], + let restoreDict = buildInfo["Restore"] as? [String: Any], + let productVersion = restoreDict["ProductVersion"] as? String, + let firmwareURL = restoreDict["FirmwareURL"] as? String + else { + continue + } + + let firmwareSHA1 = restoreDict["FirmwareSHA1"] as? String ?? "" + + // Return the first restore image found (typically the latest) + guard let downloadURL = URL(string: firmwareURL) else { + continue // Skip if URL is invalid + } + + return RestoreImageRecord( + version: productVersion, + buildNumber: buildVersion, + releaseDate: Date(), // MESU doesn't provide release date, use current date + downloadURL: downloadURL, + fileSize: 0, // Not provided by MESU + sha256Hash: "", // MESU only provides SHA1 + sha1Hash: firmwareSHA1, + isSigned: true, // MESU only lists currently signed images + isPrerelease: false, // MESU typically only has final releases + source: "mesu.apple.com", + notes: "Latest signed release from Apple MESU", + sourceUpdatedAt: lastModified // When Apple last updated MESU manifest + ) + } + + // No restore images found in the plist + return nil + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MrMacintoshFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MrMacintoshFetcher.swift new file mode 100644 index 00000000..99a64f22 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MrMacintoshFetcher.swift @@ -0,0 +1,243 @@ +// +// MrMacintoshFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +public import BushelLogging +import Foundation +import Logging +import SwiftSoup + +#if canImport(FelinePineSwift) + import FelinePineSwift +#endif + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Fetcher for macOS beta/RC restore images from Mr. Macintosh database +internal struct MrMacintoshFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [RestoreImageRecord] + + // MARK: - Error Types + + internal enum FetchError: Error { + case invalidURL + case invalidEncoding + } + + // MARK: - Internal Methods + + /// Fetch beta and RC restore images from Mr. Macintosh + internal func fetch() async throws -> [RestoreImageRecord] { + let urlString = + "https://mrmacintosh.com/apple-silicon-m1-full-macos-restore-ipsw-firmware-files-database/" + guard let url = URL(string: urlString) else { + throw FetchError.invalidURL + } + + let (data, _) = try await URLSession.shared.data(from: url) + guard let html = String(data: data, encoding: .utf8) else { + throw FetchError.invalidEncoding + } + + let doc = try SwiftSoup.parse(html) + + // Extract the page update date from UPDATED: MM/DD/YY + var pageUpdatedAt: Date? + if let strongElements = try? doc.select("strong"), + let updateElement = strongElements.first(where: { element in + (try? element.text().uppercased().starts(with: "UPDATED:")) == true + }), + let updateText = try? updateElement.text(), + let dateString = updateText.split(separator: ":").last?.trimmingCharacters(in: .whitespaces) + { + pageUpdatedAt = parseDateMMDDYY(from: String(dateString)) + if let date = pageUpdatedAt { + Self.logger.debug( + "Mr. Macintosh page last updated: \(date)" + ) + } + } + + // Find all table rows + let rows = try doc.select("table tr") + + let records = rows.compactMap { row in + parseTableRow(row, pageUpdatedAt: pageUpdatedAt) + } + + return records + } + + // MARK: - Private Methods + + /// Parse a table row into a RestoreImageRecord + private func parseTableRow(_ row: SwiftSoup.Element, pageUpdatedAt: Date?) -> RestoreImageRecord? + { + do { + let cells = try row.select("td") + guard cells.count >= 3 else { + return nil + } + + // Expected columns: Download Link | Version | Date | [Optional: Signed Status] + // Extract filename and URL from first cell + guard let linkElement = try cells[0].select("a").first(), + let downloadURLString = try? linkElement.attr("href"), + !downloadURLString.isEmpty, + let downloadURL = URL(string: downloadURLString) + else { + return nil + } + + let filename = try linkElement.text() + + // Parse filename like "UniversalMac_26.1_25B78_Restore.ipsw" + // Extract version and build from filename + guard filename.contains("UniversalMac") else { + return nil + } + + let components = filename.replacingOccurrences(of: ".ipsw", with: "") + .components(separatedBy: "_") + guard components.count >= 3 else { + return nil + } + + let version = components[1] + let buildNumber = components[2] + + // Get version from second cell (more reliable) + let versionFromCell = try cells[1].text() + + // Get date from third cell + let dateStr = try cells[2].text() + guard let releaseDate = parseDate(from: dateStr) else { + Self.logger.warning( + "Failed to parse date '\(dateStr)' for build \(buildNumber), skipping record" + ) + return nil + } + + // Check if signed (4th column if present) + let isSigned: Bool? = + cells.count >= 4 ? try cells[3].text().uppercased().contains("YES") : nil + + // Determine if it's a beta/RC release from filename or version + let isPrerelease = + filename.lowercased().contains("beta") || filename.lowercased().contains("rc") + || versionFromCell.lowercased().contains("beta") + || versionFromCell.lowercased().contains("rc") + + return RestoreImageRecord( + version: version, + buildNumber: buildNumber, + releaseDate: releaseDate, + downloadURL: downloadURL, + fileSize: 0, // Not provided + sha256Hash: "", // Not provided + sha1Hash: "", // Not provided + isSigned: isSigned, + isPrerelease: isPrerelease, + source: "mrmacintosh.com", + notes: nil, + sourceUpdatedAt: pageUpdatedAt // Date when Mr. Macintosh last updated the page + ) + } catch { + Self.logger.debug( + "Failed to parse table row: \(error)" + ) + return nil + } + } + + /// Parse date from Mr. Macintosh format (MM/DD/YY or M/D or M/DD) + private func parseDate(from string: String) -> Date? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + + // Try formats with year first + let formattersWithYear = [ + makeDateFormatter(format: "M/d/yy"), + makeDateFormatter(format: "MM/dd/yy"), + makeDateFormatter(format: "M/d/yyyy"), + makeDateFormatter(format: "MM/dd/yyyy"), + ] + + for formatter in formattersWithYear { + if let date = formatter.date(from: trimmed) { + return date + } + } + + // If no year, assume current or previous year + let formattersNoYear = [ + makeDateFormatter(format: "M/d"), + makeDateFormatter(format: "MM/dd"), + ] + + for formatter in formattersNoYear { + if let date = formatter.date(from: trimmed) { + // Add current year + let calendar = Calendar.current + let currentYear = calendar.component(.year, from: Date()) + var components = calendar.dateComponents([.month, .day], from: date) + components.year = currentYear + + // If date is in the future, use previous year + if let dateWithYear = calendar.date(from: components), dateWithYear > Date() { + components.year = currentYear - 1 + } + + return calendar.date(from: components) + } + } + + return nil + } + + /// Parse date from page update format (MM/DD/YY) + private func parseDateMMDDYY(from string: String) -> Date? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + let formatter = makeDateFormatter(format: "MM/dd/yy") + return formatter.date(from: trimmed) + } + + private func makeDateFormatter(format: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + } +} + +// MARK: - Loggable Conformance +extension MrMacintoshFetcher: Loggable { + internal static let loggingCategory: BushelLogging.Category = .hub +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/SwiftVersionFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/SwiftVersionFetcher.swift new file mode 100644 index 00000000..3400d1eb --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/SwiftVersionFetcher.swift @@ -0,0 +1,108 @@ +// +// SwiftVersionFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +import Foundation +import SwiftSoup + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Fetcher for Swift compiler versions from swiftversion.net +internal struct SwiftVersionFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [SwiftVersionRecord] + + // MARK: - Internal Models + + private struct SwiftVersionEntry { + let date: Date + let swiftVersion: String + let xcodeVersion: String + } + + internal enum FetchError: Error { + case invalidEncoding + } + + // MARK: - Type Properties + + // swiftlint:disable:next force_unwrapping + private static let swiftVersionURL = URL(string: "https://swiftversion.net")! + + // MARK: - Internal Methods + + /// Fetch all Swift versions from swiftversion.net + internal func fetch() async throws -> [SwiftVersionRecord] { + let (data, _) = try await URLSession.shared.data(from: Self.swiftVersionURL) + guard let html = String(data: data, encoding: .utf8) else { + throw FetchError.invalidEncoding + } + + let doc = try SwiftSoup.parse(html) + let rows = try doc.select("tbody tr.table-entry") + + var entries: [SwiftVersionEntry] = [] + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd MMM yy" + + for row in rows { + let cells = try row.select("td") + guard cells.count == 3 else { continue } + + let dateStr = try cells[0].text() + let swiftVer = try cells[1].text() + let xcodeVer = try cells[2].text() + + guard let date = dateFormatter.date(from: dateStr) else { + print("Warning: Could not parse date: \(dateStr)") + continue + } + + entries.append( + SwiftVersionEntry( + date: date, + swiftVersion: swiftVer, + xcodeVersion: xcodeVer + ) + ) + } + + return entries.map { entry in + SwiftVersionRecord( + version: entry.swiftVersion, + releaseDate: entry.date, + downloadURL: URL(string: "https://swift.org/download/"), // Generic download page + isPrerelease: entry.swiftVersion.contains("beta") + || entry.swiftVersion.contains("snapshot"), + notes: "Bundled with Xcode \(entry.xcodeVersion)" + ) + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/IPSWParser.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/IPSWParser.swift new file mode 100644 index 00000000..009dac43 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/IPSWParser.swift @@ -0,0 +1,231 @@ +// +// IPSWParser.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +// MARK: - Errors + +internal enum TheAppleWikiError: LocalizedError { + case invalidURL(String) + case networkError(underlying: any Error) + case parsingError(String) + case noDataFound + + internal var errorDescription: String? { + switch self { + case .invalidURL(let url): + return "Invalid URL: \(url)" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .parsingError(let details): + return "Parsing error: \(details)" + case .noDataFound: + return "No IPSW data found" + } + } +} + +// MARK: - Parser + +/// Fetches macOS IPSW metadata from TheAppleWiki.com +@available(macOS 12.0, *) +internal struct IPSWParser: Sendable { + private let baseURL = "https://theapplewiki.com" + private let apiEndpoint = "/api.php" + + /// Fetch all available IPSW versions for macOS 12+ + /// - Parameter deviceFilter: Optional device identifier to filter by (e.g., "VirtualMac2,1") + /// - Returns: Array of IPSW versions matching the filter + /// - Throws: Network errors, decoding errors, or if URL construction fails + internal func fetchAllIPSWVersions(deviceFilter: String? = nil) async throws -> [IPSWVersion] { + // Get list of Mac firmware pages + let pagesURL = try buildPagesURL() + let pagesData = try await fetchData(from: pagesURL) + let pagesResponse = try JSONDecoder().decode(ParseResponse.self, from: pagesData) + + var allVersions: [IPSWVersion] = [] + + // Extract firmware page links from content + let content = pagesResponse.parse.text.content + let versionPages = try extractVersionPages(from: content) + + // Fetch versions from each page + for pageTitle in versionPages { + let pageURL = try buildPageURL(for: pageTitle) + do { + let versions = try await parseIPSWPage(url: pageURL, deviceFilter: deviceFilter) + allVersions.append(contentsOf: versions) + } catch { + // Continue on page parse errors - some pages may be empty or malformed + continue + } + } + + guard !allVersions.isEmpty else { + throw TheAppleWikiError.noDataFound + } + + return allVersions + } + + // MARK: - Private Methods + + private func buildPagesURL() throws -> URL { + guard + let url = URL(string: baseURL + apiEndpoint + "?action=parse&page=Firmware/Mac&format=json") + else { + throw TheAppleWikiError.invalidURL("Firmware/Mac") + } + return url + } + + private func buildPageURL(for pageTitle: String) throws -> URL { + guard let encoded = pageTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: baseURL + apiEndpoint + "?action=parse&page=\(encoded)&format=json") + else { + throw TheAppleWikiError.invalidURL(pageTitle) + } + return url + } + + private func fetchData(from url: URL) async throws -> Data { + do { + let (data, _) = try await URLSession.shared.data(from: url) + return data + } catch { + throw TheAppleWikiError.networkError(underlying: error) + } + } + + private func extractVersionPages(from content: String) throws -> [String] { + let pattern = #"Firmware/Mac/(\d+)\.x"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + throw TheAppleWikiError.parsingError("Invalid regex pattern") + } + + let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) + + let versionPages = matches.compactMap { match -> String? in + guard let range = Range(match.range(at: 1), in: content), + let version = Double(content[range]), + version >= 12 + else { + return nil + } + return "Firmware/Mac/\(Int(version)).x" + } + + return versionPages + } + + private func parseIPSWPage(url: URL, deviceFilter: String?) async throws -> [IPSWVersion] { + let data = try await fetchData(from: url) + let response = try JSONDecoder().decode(ParseResponse.self, from: data) + + var versions: [IPSWVersion] = [] + + // Split content into rows (basic HTML parsing) + let rows = response.parse.text.content.components(separatedBy: " String? in + // Extract text between td tags, removing HTML + guard let endIndex = cell.range(of: "")?.lowerBound + else { + return nil + } + let content = cell[..]+>", with: "", options: .regularExpression) + } + + guard cells.count >= 6 else { continue } + + let version = cells[0] + let buildNumber = cells[1] + let deviceModel = cells[2] + let fileName = cells[3] + + // Skip if filename doesn't end with ipsw + guard fileName.lowercased().hasSuffix("ipsw") else { continue } + + // Apply device filter if specified + if let filter = deviceFilter, !deviceModel.contains(filter) { + continue + } + + let fileSize = cells[4] + let sha1 = cells[5] + + let releaseDate: Date? = cells.count > 6 ? parseDate(cells[6]) : nil + let url: URL? = parseURL(from: cells[3]) + + versions.append( + IPSWVersion( + version: version, + buildNumber: buildNumber, + deviceModel: deviceModel, + fileName: fileName, + fileSize: fileSize, + sha1: sha1, + releaseDate: releaseDate, + url: url + ) + ) + } + + return versions + } + + private func parseDate(_ str: String) -> Date? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: str) + } + + private func parseURL(from text: String) -> URL? { + // Extract URL from possible HTML link in text + let pattern = #"href="([^"]+)"# + guard let match = text.range(of: pattern, options: .regularExpression) else { + return nil + } + + let urlString = String(text[match]) + .replacingOccurrences(of: "href=\"", with: "") + .replacingOccurrences(of: "\"", with: "") + + return URL(string: urlString) + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/IPSWVersion.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/IPSWVersion.swift new file mode 100644 index 00000000..b020a458 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/IPSWVersion.swift @@ -0,0 +1,89 @@ +// +// IPSWVersion.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// IPSW metadata from TheAppleWiki +internal struct IPSWVersion: Codable, Sendable { + internal let version: String + internal let buildNumber: String + internal let deviceModel: String + internal let fileName: String + internal let fileSize: String + internal let sha1: String + internal let releaseDate: Date? + internal let url: URL? + + // MARK: - Computed Properties + + /// Parse file size string to Int for CloudKit + /// Examples: "10.2 GB" -> bytes, "1.5 MB" -> bytes + internal var fileSizeInBytes: Int? { + let components = fileSize.components(separatedBy: " ") + guard components.count == 2, + let size = Double(components[0]) + else { + return nil + } + + let unit = components[1].uppercased() + let multiplier: Double = + switch unit { + case "GB": 1_000_000_000 + case "MB": 1_000_000 + case "KB": 1_000 + case "BYTES", "B": 1 + default: 0 + } + + guard multiplier > 0 else { + return nil + } + return Int(size * multiplier) + } + + /// Detect if this is a prerelease version (beta, RC, etc.) + internal var isPrerelease: Bool { + let lowercased = version.lowercased() + return lowercased.contains("beta") + || lowercased.contains("rc") + || lowercased.contains("gm seed") + || lowercased.contains("developer preview") + } + + /// Validate that all required fields are present + internal var isValid: Bool { + !version.isEmpty + && !buildNumber.isEmpty + && !deviceModel.isEmpty + && !fileName.isEmpty + && !sha1.isEmpty + && url != nil + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseContent.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseContent.swift new file mode 100644 index 00000000..709c4c7a --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseContent.swift @@ -0,0 +1,36 @@ +// +// ParseContent.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Parse content container +internal struct ParseContent: Codable, Sendable { + internal let title: String + internal let text: TextContent +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseResponse.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseResponse.swift new file mode 100644 index 00000000..0ca9cfde --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseResponse.swift @@ -0,0 +1,35 @@ +// +// ParseResponse.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Root response from TheAppleWiki parse API +internal struct ParseResponse: Codable, Sendable { + internal let parse: ParseContent +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/TextContent.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/TextContent.swift new file mode 100644 index 00000000..73258bd6 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/TextContent.swift @@ -0,0 +1,39 @@ +// +// TextContent.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Text content with HTML +internal struct TextContent: Codable, Sendable { + internal enum CodingKeys: String, CodingKey { + case content = "*" + } + + internal let content: String +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift new file mode 100644 index 00000000..2df6e65d --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift @@ -0,0 +1,107 @@ +// +// TheAppleWikiFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +import BushelUtilities +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Fetcher for macOS restore images using TheAppleWiki.com +@available( + *, deprecated, message: "Use AppleDBFetcher instead for more reliable and up-to-date data" +) +internal struct TheAppleWikiFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [RestoreImageRecord] + /// Static base URL for TheAppleWiki API + private static let wikiAPIURL: URL = { + guard + let url = URL( + string: "https://theapplewiki.com/api.php?action=parse&page=Firmware/Mac&format=json" + ) + else { + fatalError("Invalid static URL for TheAppleWiki API - this should never happen") + } + return url + }() + + /// Fetch all macOS restore images from TheAppleWiki + internal func fetch() async throws -> [RestoreImageRecord] { + // Fetch Last-Modified header from TheAppleWiki API + let apiURL = Self.wikiAPIURL + + #if canImport(FoundationNetworking) + // Use FoundationNetworking.URLSession directly on Linux + let lastModified = await FoundationNetworking.URLSession.shared.fetchLastModified( + from: apiURL + ) + #else + let lastModified = await URLSession.shared.fetchLastModified(from: apiURL) + #endif + + let parser = IPSWParser() + + // Fetch all versions without device filtering (UniversalMac images work for all devices) + let versions = try await parser.fetchAllIPSWVersions(deviceFilter: nil) + + // Map to RestoreImageRecord, filtering out only invalid entries + // Deduplication happens later in DataSourcePipeline + return + versions + .filter { $0.isValid } + .compactMap { version -> RestoreImageRecord? in + // Skip if we can't get essential data + guard let downloadURL = version.url, + let fileSize = version.fileSizeInBytes + else { + return nil + } + + // Use current date as fallback if release date is missing + let releaseDate = version.releaseDate ?? Date() + + return RestoreImageRecord( + version: version.version, + buildNumber: version.buildNumber, + releaseDate: releaseDate, + downloadURL: downloadURL, + fileSize: fileSize, + sha256Hash: "", // Not available from TheAppleWiki + sha1Hash: version.sha1, + isSigned: nil, // Unknown - will be merged from other sources + isPrerelease: version.isPrerelease, + source: "theapplewiki.com", + notes: "Device: \(version.deviceModel)", + sourceUpdatedAt: lastModified // When TheAppleWiki API was last updated + ) + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/VirtualBuddyFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/VirtualBuddyFetcher.swift new file mode 100644 index 00000000..54efdb3b --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/VirtualBuddyFetcher.swift @@ -0,0 +1,184 @@ +// +// VirtualBuddyFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +import BushelUtilities +import BushelVirtualBuddy +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Fetcher for enriching restore images with VirtualBuddy TSS signing status +internal struct VirtualBuddyFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [RestoreImageRecord] + + /// Base URL components for VirtualBuddy TSS API endpoint + // swiftlint:disable:next force_unwrapping + private static let baseURLComponents = URLComponents( + string: "https://tss.virtualbuddy.app/v1/status" + )! + + private let apiKey: String + private let decoder: JSONDecoder + private let urlSession: URLSession + + /// Failable initializer that reads API key from environment variable + internal init?() { + guard let key = ProcessInfo.processInfo.environment["VIRTUALBUDDY_API_KEY"], !key.isEmpty else { + return nil + } + self.init(apiKey: key) + } + + /// Explicit initializer with API key + internal init( + apiKey: String, + decoder: JSONDecoder = JSONDecoder(), + urlSession: URLSession = .shared + ) { + self.apiKey = apiKey + self.decoder = decoder + self.urlSession = urlSession + } + + /// DataSourceFetcher protocol requirement - returns empty for enrichment fetchers + internal func fetch() async throws -> [RestoreImageRecord] { + [] + } + + /// Enrich existing restore images with VirtualBuddy TSS signing status + internal func fetch(existingImages: [RestoreImageRecord]) async throws -> [RestoreImageRecord] { + var enrichedImages: [RestoreImageRecord] = [] + + // Count images that need VirtualBuddy checking (non-file URLs) + let imagesToCheck = existingImages.filter { $0.downloadURL.scheme != "file" } + let totalCount = imagesToCheck.count + var processedCount = 0 + + for image in existingImages { + // Skip file URLs (VirtualBuddy API doesn't support them) + guard image.downloadURL.scheme != "file" else { + enrichedImages.append(image) + continue + } + + processedCount += 1 + + do { + let response = try await checkSigningStatus(for: image.downloadURL) + + // Validate build number matches + guard response.build == image.buildNumber else { + print( + " ⚠️ VirtualBuddy: \(image.buildNumber) - build mismatch: " + + "expected \(image.buildNumber), got \(response.build) (\(processedCount)/\(totalCount))" + ) + enrichedImages.append(image) + continue + } + + // Create enriched record with VirtualBuddy data + var enriched = image + enriched.isSigned = response.isSigned + enriched.source = "tss.virtualbuddy.app" + enriched.sourceUpdatedAt = Date() // Real-time TSS check + enriched.notes = response.message // TSS status message + + // Show result with signing status + let statusEmoji = response.isSigned ? "✅" : "❌" + let statusText = response.isSigned ? "signed" : "unsigned" + print( + " \(statusEmoji) VirtualBuddy: \(image.buildNumber) - " + + "\(statusText) (\(processedCount)/\(totalCount))" + ) + + enrichedImages.append(enriched) + } catch { + print( + " ⚠️ VirtualBuddy: \(image.buildNumber) - error: \(error) (\(processedCount)/\(totalCount))" + ) + enrichedImages.append(image) // Keep original on error + } + + // Add random delay between requests to respect rate limit (2 req/5 sec) + // Only delay if there are more images to process + if processedCount < totalCount { + let randomDelay = Double.random(in: 2.5...3.5) + try await Task.sleep(for: .seconds(randomDelay), tolerance: .seconds(1)) + } + } + + return enrichedImages + } + + /// Check signing status for an IPSW URL using VirtualBuddy TSS API + private func checkSigningStatus(for ipswURL: URL) async throws -> VirtualBuddySig { + // Build endpoint URL with API key and IPSW URL + var components = Self.baseURLComponents + components.queryItems = [ + URLQueryItem(name: "apiKey", value: apiKey), + URLQueryItem(name: "ipsw", value: ipswURL.absoluteString), + ] + + guard let endpointURL = components.url else { + throw VirtualBuddyFetcherError.invalidURL + } + + // Fetch data from API + let data: Data + let response: URLResponse + do { + (data, response) = try await urlSession.data(from: endpointURL) + } catch { + throw VirtualBuddyFetcherError.networkError(error) + } + + // Check HTTP status + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { + throw VirtualBuddyFetcherError.httpError(httpResponse.statusCode) + } + + // Decode response + do { + return try decoder.decode(VirtualBuddySig.self, from: data) + } catch { + throw VirtualBuddyFetcherError.decodingError(error) + } + } +} + +/// Errors that can occur during VirtualBuddy fetching +internal enum VirtualBuddyFetcherError: Error { + case invalidURL + case networkError(any Error) + case httpError(Int) + case decodingError(any Error) +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/XcodeReleasesFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/XcodeReleasesFetcher.swift new file mode 100644 index 00000000..8e719eb3 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/XcodeReleasesFetcher.swift @@ -0,0 +1,204 @@ +// +// XcodeReleasesFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import BushelLogging +import Foundation +import Logging + +#if canImport(FelinePineSwift) + import FelinePineSwift +#endif + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Fetcher for Xcode releases from xcodereleases.com JSON API +public struct XcodeReleasesFetcher: DataSourceFetcher, Sendable { + public typealias Record = [XcodeVersionRecord] + + // MARK: - API Models + + private struct XcodeRelease: Codable { + struct Checksums: Codable { + // API provides checksums but we don't use them currently + } + + struct Compilers: Codable { + struct Compiler: Codable { + let number: String? + } + + let swift: [Compiler]? + } + + struct ReleaseDate: Codable { + let day: Int + let month: Int + let year: Int + + var toDate: Date { + let components = DateComponents(year: year, month: month, day: day) + return Calendar.current.date(from: components) ?? Date() + } + } + + struct Links: Codable { + struct Download: Codable { + let url: String + } + + struct Notes: Codable { + let url: String + } + + let download: Download? + let notes: Notes? + } + + struct SDKs: Codable { + struct SDK: Codable { + let number: String? + } + + let iOS: [SDK]? + let macOS: [SDK]? + let tvOS: [SDK]? + let visionOS: [SDK]? + let watchOS: [SDK]? + } + + struct Version: Codable { + struct Release: Codable { + let beta: Int? + let rc: Int? + + var isPrerelease: Bool { + beta != nil || rc != nil + } + } + + let build: String + let number: String + let release: Release + } + + let checksums: Checksums? + let compilers: Compilers? + let date: ReleaseDate + let links: Links? + let name: String + let requires: String + let sdks: SDKs? + let version: Version + } + + // MARK: - Type Properties + + // swiftlint:disable:next force_unwrapping + private static let xcodeReleasesURL = URL(string: "https://xcodereleases.com/data.json")! + + // MARK: - Initializers + + public init() {} + + // MARK: - Public API + + /// Fetch all Xcode releases from xcodereleases.com + public func fetch() async throws -> [XcodeVersionRecord] { + let (data, _) = try await URLSession.shared.data(from: Self.xcodeReleasesURL) + let releases = try JSONDecoder().decode([XcodeRelease].self, from: data) + + return releases.map { release in + // Build SDK versions JSON (if SDK info is available) + var sdkDict: [String: String] = [:] + if let sdks = release.sdks { + if let ios = sdks.iOS?.first, let number = ios.number { sdkDict["iOS"] = number } + if let macos = sdks.macOS?.first, let number = macos.number { sdkDict["macOS"] = number } + if let tvos = sdks.tvOS?.first, let number = tvos.number { sdkDict["tvOS"] = number } + if let visionos = sdks.visionOS?.first, let number = visionos.number { + sdkDict["visionOS"] = number + } + if let watchos = sdks.watchOS?.first, let number = watchos.number { + sdkDict["watchOS"] = number + } + } + + // Encode SDK dictionary to JSON string with proper error handling + let sdkString: String? = { + do { + let data = try JSONEncoder().encode(sdkDict) + return String(data: data, encoding: .utf8) + } catch { + Self.logger.warning( + "Failed to encode SDK versions for \(release.name): \(error)" + ) + return nil + } + }() + + // Extract Swift version (if compilers info is available) + let swiftVersion = release.compilers?.swift?.first?.number + + // Store requires string temporarily for later resolution + // Format: "REQUIRES:|NOTES_URL:" + var notesField = "REQUIRES:\(release.requires)" + if let notesURL = release.links?.notes?.url { + notesField += "|NOTES_URL:\(notesURL)" + } + + // Convert download URL string to URL if available + let downloadURL: URL? = { + guard let urlString = release.links?.download?.url else { + return nil + } + return URL(string: urlString) + }() + + return XcodeVersionRecord( + version: release.version.number, + buildNumber: release.version.build, + releaseDate: release.date.toDate, + downloadURL: downloadURL, + fileSize: nil, // Not provided by API + isPrerelease: release.version.release.isPrerelease, + minimumMacOS: nil, // Will be resolved in DataSourcePipeline + includedSwiftVersion: swiftVersion.map { "SwiftVersion-\($0)" }, + sdkVersions: sdkString, + notes: notesField + ) + } + } +} + +// MARK: - Loggable Conformance +extension XcodeReleasesFetcher: Loggable { + public static let loggingCategory: BushelLogging.Category = .hub +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/DataSourceMetadata+CloudKit.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/DataSourceMetadata+CloudKit.swift new file mode 100644 index 00000000..7b87e7d9 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/DataSourceMetadata+CloudKit.swift @@ -0,0 +1,92 @@ +// +// DataSourceMetadata+CloudKit.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import BushelUtilities +public import Foundation +public import MistKit + +// MARK: - CloudKitRecord Conformance + +extension DataSourceMetadata: CloudKitRecord { + public static var cloudKitRecordType: String { "DataSourceMetadata" } + + public static func from(recordInfo: RecordInfo) -> Self? { + guard let sourceName = recordInfo.fields["sourceName"]?.stringValue, + let recordTypeName = recordInfo.fields["recordTypeName"]?.stringValue, + let lastFetchedAt = recordInfo.fields["lastFetchedAt"]?.dateValue + else { + return nil + } + + return DataSourceMetadata( + sourceName: sourceName, + recordTypeName: recordTypeName, + lastFetchedAt: lastFetchedAt, + sourceUpdatedAt: recordInfo.fields["sourceUpdatedAt"]?.dateValue, + recordCount: recordInfo.fields["recordCount"]?.intValue ?? 0, + fetchDurationSeconds: recordInfo.fields["fetchDurationSeconds"]?.doubleValue ?? 0, + lastError: recordInfo.fields["lastError"]?.stringValue + ) + } + + public static func formatForDisplay(_ recordInfo: RecordInfo) -> String { + let sourceName = recordInfo.fields["sourceName"]?.stringValue ?? "Unknown" + let recordTypeName = recordInfo.fields["recordTypeName"]?.stringValue ?? "Unknown" + let lastFetchedAt = recordInfo.fields["lastFetchedAt"]?.dateValue + let recordCount = recordInfo.fields["recordCount"]?.intValue ?? 0 + + let dateStr = lastFetchedAt.map { Formatters.dateTimeFormat.format($0) } ?? "Unknown" + + var output = "\n \(sourceName) → \(recordTypeName)\n" + output += " Last fetched: \(dateStr) | Records: \(recordCount)" + return output + } + + public func toCloudKitFields() -> [String: FieldValue] { + var fields: [String: FieldValue] = [ + "sourceName": .string(sourceName), + "recordTypeName": .string(recordTypeName), + "lastFetchedAt": .date(lastFetchedAt), + "recordCount": .int64(recordCount), + "fetchDurationSeconds": .double(fetchDurationSeconds), + ] + + // Optional fields + if let sourceUpdatedAt { + fields["sourceUpdatedAt"] = .date(sourceUpdatedAt) + } + + if let lastError { + fields["lastError"] = .string(lastError) + } + + return fields + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/FieldValue+URL.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/FieldValue+URL.swift new file mode 100644 index 00000000..6aba7070 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/FieldValue+URL.swift @@ -0,0 +1,73 @@ +// +// FieldValue+URL.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit + +extension FieldValue { + /// Extract a URL from a FieldValue + /// + /// This convenience property attempts to convert a string FieldValue back to a URL. + /// Returns `nil` if the FieldValue is not a string type or if the string cannot be + /// parsed as a valid URL. + /// + /// ## Usage + /// ```swift + /// let fieldValue: FieldValue = .string("https://example.com/file.dmg") + /// if let url = fieldValue.urlValue { + /// print(url.absoluteString) // "https://example.com/file.dmg" + /// } + /// ``` + /// + /// - Returns: The URL if this is a string FieldValue with a valid URL format, otherwise `nil` + public var urlValue: URL? { + if case .string(let value) = self { + return URL(string: value) + } + return nil + } + + /// Create a string FieldValue from a URL + /// + /// This convenience initializer converts a URL to its absolute string representation + /// for storage in CloudKit. CloudKit stores URLs as STRING fields, so this provides + /// automatic conversion. + /// + /// ## Usage + /// ```swift + /// let url = URL(string: "https://example.com/file.dmg")! + /// let fieldValue = FieldValue(url: url) + /// // Equivalent to: FieldValue.string("https://example.com/file.dmg") + /// ``` + /// + /// - Parameter url: The URL to convert to a FieldValue + public init(url: URL) { + self = .string(url.absoluteString) + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/RestoreImageRecord+CloudKit.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/RestoreImageRecord+CloudKit.swift new file mode 100644 index 00000000..dace29b3 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/RestoreImageRecord+CloudKit.swift @@ -0,0 +1,113 @@ +// +// RestoreImageRecord+CloudKit.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import BushelUtilities +public import Foundation +public import MistKit + +// MARK: - CloudKitRecord Conformance + +extension RestoreImageRecord: @retroactive CloudKitRecord { + public static var cloudKitRecordType: String { "RestoreImage" } + + public static func from(recordInfo: RecordInfo) -> Self? { + guard let version = recordInfo.fields["version"]?.stringValue, + let buildNumber = recordInfo.fields["buildNumber"]?.stringValue, + let releaseDate = recordInfo.fields["releaseDate"]?.dateValue, + let downloadURL = recordInfo.fields["downloadURL"]?.urlValue, + let fileSize = recordInfo.fields["fileSize"]?.intValue, + let sha256Hash = recordInfo.fields["sha256Hash"]?.stringValue, + let sha1Hash = recordInfo.fields["sha1Hash"]?.stringValue, + let source = recordInfo.fields["source"]?.stringValue + else { + return nil + } + + return RestoreImageRecord( + version: version, + buildNumber: buildNumber, + releaseDate: releaseDate, + downloadURL: downloadURL, + fileSize: fileSize, + sha256Hash: sha256Hash, + sha1Hash: sha1Hash, + isSigned: recordInfo.fields["isSigned"]?.boolValue, + isPrerelease: recordInfo.fields["isPrerelease"]?.boolValue ?? false, + source: source, + notes: recordInfo.fields["notes"]?.stringValue, + sourceUpdatedAt: recordInfo.fields["sourceUpdatedAt"]?.dateValue + ) + } + + public static func formatForDisplay(_ recordInfo: RecordInfo) -> String { + let build = recordInfo.fields["buildNumber"]?.stringValue ?? "Unknown" + let signed = recordInfo.fields["isSigned"]?.boolValue ?? false + let prerelease = recordInfo.fields["isPrerelease"]?.boolValue ?? false + let source = recordInfo.fields["source"]?.stringValue ?? "Unknown" + let size = recordInfo.fields["fileSize"]?.intValue ?? 0 + + let signedStr = signed ? "✅ Signed" : "❌ Unsigned" + let prereleaseStr = prerelease ? "[Beta/RC]" : "" + let sizeStr = Formatters.fileSizeFormat.format(Int64(size)) + + var output = " \(build) \(prereleaseStr)\n" + output += " \(signedStr) | Size: \(sizeStr) | Source: \(source)" + return output + } + + public func toCloudKitFields() -> [String: FieldValue] { + var fields: [String: FieldValue] = [ + "version": .string(version), + "buildNumber": .string(buildNumber), + "releaseDate": .date(releaseDate), + "downloadURL": FieldValue(url: downloadURL), + "fileSize": .int64(fileSize), + "sha256Hash": .string(sha256Hash), + "sha1Hash": .string(sha1Hash), + "isPrerelease": FieldValue(booleanValue: isPrerelease), + "source": .string(source), + ] + + // Optional fields + if let isSigned { + fields["isSigned"] = FieldValue(booleanValue: isSigned) + } + + if let notes { + fields["notes"] = .string(notes) + } + + if let sourceUpdatedAt { + fields["sourceUpdatedAt"] = .date(sourceUpdatedAt) + } + + return fields + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/SwiftVersionRecord+CloudKit.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/SwiftVersionRecord+CloudKit.swift new file mode 100644 index 00000000..a024c056 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/SwiftVersionRecord+CloudKit.swift @@ -0,0 +1,85 @@ +// +// SwiftVersionRecord+CloudKit.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import BushelUtilities +public import Foundation +public import MistKit + +// MARK: - CloudKitRecord Conformance + +extension SwiftVersionRecord: @retroactive CloudKitRecord { + public static var cloudKitRecordType: String { "SwiftVersion" } + + public static func from(recordInfo: RecordInfo) -> Self? { + guard let version = recordInfo.fields["version"]?.stringValue, + let releaseDate = recordInfo.fields["releaseDate"]?.dateValue + else { + return nil + } + + return SwiftVersionRecord( + version: version, + releaseDate: releaseDate, + downloadURL: recordInfo.fields["downloadURL"]?.urlValue, + isPrerelease: recordInfo.fields["isPrerelease"]?.boolValue ?? false, + notes: recordInfo.fields["notes"]?.stringValue + ) + } + + public static func formatForDisplay(_ recordInfo: RecordInfo) -> String { + let version = recordInfo.fields["version"]?.stringValue ?? "Unknown" + let releaseDate = recordInfo.fields["releaseDate"]?.dateValue + + let dateStr = releaseDate.map { Formatters.dateFormat.format($0) } ?? "Unknown" + + var output = "\n Swift \(version)\n" + output += " Released: \(dateStr)" + return output + } + + public func toCloudKitFields() -> [String: FieldValue] { + var fields: [String: FieldValue] = [ + "version": .string(version), + "releaseDate": .date(releaseDate), + "isPrerelease": FieldValue(booleanValue: isPrerelease), + ] + + // Optional fields + if let downloadURL { + fields["downloadURL"] = FieldValue(url: downloadURL) + } + + if let notes { + fields["notes"] = .string(notes) + } + + return fields + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/XcodeVersionRecord+CloudKit.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/XcodeVersionRecord+CloudKit.swift new file mode 100644 index 00000000..66f7ad57 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Extensions/XcodeVersionRecord+CloudKit.swift @@ -0,0 +1,121 @@ +// +// XcodeVersionRecord+CloudKit.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelFoundation +public import BushelUtilities +public import Foundation +public import MistKit + +// MARK: - CloudKitRecord Conformance + +extension XcodeVersionRecord: @retroactive CloudKitRecord { + public static var cloudKitRecordType: String { "XcodeVersion" } + + public static func from(recordInfo: RecordInfo) -> Self? { + guard let version = recordInfo.fields["version"]?.stringValue, + let buildNumber = recordInfo.fields["buildNumber"]?.stringValue, + let releaseDate = recordInfo.fields["releaseDate"]?.dateValue + else { + return nil + } + + return XcodeVersionRecord( + version: version, + buildNumber: buildNumber, + releaseDate: releaseDate, + downloadURL: recordInfo.fields["downloadURL"]?.urlValue, + fileSize: recordInfo.fields["fileSize"]?.intValue, + isPrerelease: recordInfo.fields["isPrerelease"]?.boolValue ?? false, + minimumMacOS: recordInfo.fields["minimumMacOS"]?.referenceValue?.recordName, + includedSwiftVersion: recordInfo.fields["includedSwiftVersion"]?.referenceValue?.recordName, + sdkVersions: recordInfo.fields["sdkVersions"]?.stringValue, + notes: recordInfo.fields["notes"]?.stringValue + ) + } + + public static func formatForDisplay(_ recordInfo: RecordInfo) -> String { + let version = recordInfo.fields["version"]?.stringValue ?? "Unknown" + let build = recordInfo.fields["buildNumber"]?.stringValue ?? "Unknown" + let releaseDate = recordInfo.fields["releaseDate"]?.dateValue + let size = recordInfo.fields["fileSize"]?.intValue ?? 0 + + let dateStr = releaseDate.map { Formatters.dateFormat.format($0) } ?? "Unknown" + let sizeStr = Formatters.fileSizeFormat.format(Int64(size)) + + var output = "\n \(version) (Build \(build))\n" + output += " Released: \(dateStr) | Size: \(sizeStr)" + return output + } + + public func toCloudKitFields() -> [String: FieldValue] { + var fields: [String: FieldValue] = [ + "version": .string(version), + "buildNumber": .string(buildNumber), + "releaseDate": .date(releaseDate), + "isPrerelease": FieldValue(booleanValue: isPrerelease), + ] + + // Optional fields + if let downloadURL { + fields["downloadURL"] = FieldValue(url: downloadURL) + } + + if let fileSize { + fields["fileSize"] = .int64(fileSize) + } + + if let minimumMacOS { + fields["minimumMacOS"] = .reference( + FieldValue.Reference( + recordName: minimumMacOS, + action: nil + ) + ) + } + + if let includedSwiftVersion { + fields["includedSwiftVersion"] = .reference( + FieldValue.Reference( + recordName: includedSwiftVersion, + action: nil + ) + ) + } + + if let sdkVersions { + fields["sdkVersions"] = .string(sdkVersions) + } + + if let notes { + fields["notes"] = .string(notes) + } + + return fields + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift new file mode 100644 index 00000000..413caff6 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift @@ -0,0 +1,80 @@ +// +// ConsoleOutput.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Console output control for CLI interface +/// +/// Provides user-facing console output with verbose mode support. +/// This is separate from logging, which is used for debugging and monitoring. +/// +/// **Important**: All output goes to stderr to keep stdout clean for structured output (JSON, etc.) +public enum ConsoleOutput { + /// Global verbose mode flag + /// + /// Note: This is marked with `nonisolated(unsafe)` because it's set once at startup + /// before any concurrent access and then only read. This pattern is safe for CLI tools. + nonisolated(unsafe) public static var isVerbose = false + + /// Print to stderr (keeping stdout clean for structured output) + /// + /// This is a drop-in replacement for Swift's `print()` that writes to stderr instead of stdout. + /// Use this throughout the codebase to ensure JSON output on stdout remains clean. + public static func print(_ message: String) { + if let data = (message + "\n").data(using: .utf8) { + FileHandle.standardError.write(data) + } + } + + /// Print verbose message only when verbose mode is enabled + public static func verbose(_ message: String) { + guard isVerbose else { return } + ConsoleOutput.print(" \(message)") + } + + /// Print standard informational message + public static func info(_ message: String) { + ConsoleOutput.print(message) + } + + /// Print success message + public static func success(_ message: String) { + ConsoleOutput.print(" ✓ \(message)") + } + + /// Print warning message + public static func warning(_ message: String) { + ConsoleOutput.print(" ⚠️ \(message)") + } + + /// Print error message + public static func error(_ message: String) { + ConsoleOutput.print(" ❌ \(message)") + } +} diff --git a/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigKey.swift b/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigKey.swift new file mode 100644 index 00000000..cac3ef08 --- /dev/null +++ b/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigKey.swift @@ -0,0 +1,181 @@ +// +// ConfigKey.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Generic Configuration Key + +/// Configuration key for values with default fallbacks +/// +/// Use `ConfigKey` when a configuration value has a sensible default +/// that should be used when not provided by the user. The `read()` method +/// will always return a non-optional value. +/// +/// Example: +/// ```swift +/// let containerID = ConfigKey( +/// base: "cloudkit.container_id", +/// default: "iCloud.com.brightdigit.Bushel" +/// ) +/// // read(containerID) returns String (non-optional) +/// ``` +public struct ConfigKey: ConfigurationKey, Sendable { + private let baseKey: String? + private let styles: [ConfigKeySource: any NamingStyle] + private let explicitKeys: [ConfigKeySource: String] + public let defaultValue: Value // Non-optional! + + /// Initialize with explicit CLI and ENV keys and required default + public init(cli: String? = nil, env: String? = nil, default defaultVal: Value) { + self.baseKey = nil + self.styles = [:] + var keys: [ConfigKeySource: String] = [:] + if let cli = cli { keys[.commandLine] = cli } + if let env = env { keys[.environment] = env } + self.explicitKeys = keys + self.defaultValue = defaultVal + } + + /// Initialize from a base key string with naming styles and required default + /// - Parameters: + /// - base: Base key string (e.g., "cloudkit.container_id") + /// - styles: Dictionary mapping sources to naming styles + /// - defaultVal: Required default value + public init( + base: String, + styles: [ConfigKeySource: any NamingStyle], + default defaultVal: Value + ) { + self.baseKey = base + self.styles = styles + self.explicitKeys = [:] + self.defaultValue = defaultVal + } + + /// Convenience initializer with standard naming conventions and required default + /// - Parameters: + /// - base: Base key string (e.g., "cloudkit.container_id") + /// - envPrefix: Prefix for environment variable (defaults to nil) + /// - defaultVal: Required default value + public init(_ base: String, envPrefix: String? = nil, default defaultVal: Value) { + self.baseKey = base + self.styles = [ + .commandLine: StandardNamingStyle.dotSeparated, + .environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix), + ] + self.explicitKeys = [:] + self.defaultValue = defaultVal + } + + public func key(for source: ConfigKeySource) -> String? { + // Check for explicit key first + if let explicit = explicitKeys[source] { + return explicit + } + + // Generate from base key and style + guard let base = baseKey, let style = styles[source] else { + return nil + } + + return style.transform(base) + } +} + +extension ConfigKey: CustomDebugStringConvertible { + public var debugDescription: String { + let cliKey = key(for: .commandLine) ?? "nil" + let envKey = key(for: .environment) ?? "nil" + return "ConfigKey(cli: \(cliKey), env: \(envKey), default: \(defaultValue))" + } +} + +// MARK: - Convenience Initializers for BUSHEL Prefix + +extension ConfigKey { + /// Convenience initializer for keys with BUSHEL prefix + /// - Parameters: + /// - base: Base key string (e.g., "sync.dry_run") + /// - defaultVal: Required default value + public init(bushelPrefixed base: String, default defaultVal: Value) { + self.init(base, envPrefix: "BUSHEL", default: defaultVal) + } +} + +// MARK: - Specialized Initializers for Booleans + +extension ConfigKey where Value == Bool { + /// Non-optional default value accessor for booleans + @available(*, deprecated, message: "Use defaultValue directly instead") + public var boolDefault: Bool { + defaultValue // Already non-optional! + } + + /// Initialize a boolean configuration key with non-optional default + /// - Parameters: + /// - cli: Command-line argument name + /// - env: Environment variable name + /// - defaultVal: Default value (defaults to false) + public init(cli: String, env: String, default defaultVal: Bool = false) { + self.baseKey = nil + self.styles = [:] + var keys: [ConfigKeySource: String] = [:] + keys[.commandLine] = cli + keys[.environment] = env + self.explicitKeys = keys + self.defaultValue = defaultVal + } + + /// Initialize a boolean configuration key from base string + /// - Parameters: + /// - base: Base key string (e.g., "sync.verbose") + /// - envPrefix: Prefix for environment variable (defaults to nil) + /// - defaultVal: Default value (defaults to false) + public init(_ base: String, envPrefix: String? = nil, default defaultVal: Bool = false) { + self.baseKey = base + self.styles = [ + .commandLine: StandardNamingStyle.dotSeparated, + .environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix), + ] + self.explicitKeys = [:] + self.defaultValue = defaultVal + } +} + +// MARK: - BUSHEL Prefix Convenience + +extension ConfigKey where Value == Bool { + /// Convenience initializer for boolean keys with BUSHEL prefix + /// - Parameters: + /// - base: Base key string (e.g., "sync.verbose") + /// - defaultVal: Default value (defaults to false) + public init(bushelPrefixed base: String, default defaultVal: Bool = false) { + self.init(base, envPrefix: "BUSHEL", default: defaultVal) + } +} diff --git a/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigurationKey.swift b/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigurationKey.swift new file mode 100644 index 00000000..341a110f --- /dev/null +++ b/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigurationKey.swift @@ -0,0 +1,84 @@ +// +// ConfigurationKey.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Configuration Key Source + +/// Source for configuration keys (CLI arguments or environment variables) +public enum ConfigKeySource: CaseIterable, Sendable { + /// Command-line arguments (e.g., --cloudkit-container-id) + case commandLine + + /// Environment variables (e.g., CLOUDKIT_CONTAINER_ID) + case environment +} + +// MARK: - Naming Style + +/// Protocol for transforming base key strings into different naming conventions +public protocol NamingStyle: Sendable { + /// Transform a base key string according to this naming style + /// - Parameter base: Base key string (e.g., "cloudkit.container_id") + /// - Returns: Transformed key string + func transform(_ base: String) -> String +} + +/// Common naming styles for configuration keys +public enum StandardNamingStyle: NamingStyle, Sendable { + /// Dot-separated lowercase (e.g., "cloudkit.container_id") + case dotSeparated + + /// Screaming snake case with prefix (e.g., "BUSHEL_CLOUDKIT_CONTAINER_ID") + case screamingSnakeCase(prefix: String?) + + public func transform(_ base: String) -> String { + switch self { + case .dotSeparated: + return base + + case .screamingSnakeCase(let prefix): + let snakeCase = base.uppercased().replacingOccurrences(of: ".", with: "_") + if let prefix = prefix { + return "\(prefix)_\(snakeCase)" + } + return snakeCase + } + } +} + +// MARK: - Configuration Key Protocol + +/// Protocol for configuration keys that support multiple sources +public protocol ConfigurationKey: Sendable { + /// Get the key string for a specific source + /// - Parameter source: The configuration source (CLI or ENV) + /// - Returns: The key string for that source, or nil if the key doesn't support that source + func key(for source: ConfigKeySource) -> String? +} diff --git a/Examples/BushelCloud/Sources/ConfigKeyKit/OptionalConfigKey.swift b/Examples/BushelCloud/Sources/ConfigKeyKit/OptionalConfigKey.swift new file mode 100644 index 00000000..8e32aaec --- /dev/null +++ b/Examples/BushelCloud/Sources/ConfigKeyKit/OptionalConfigKey.swift @@ -0,0 +1,117 @@ +// +// OptionalConfigKey.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Optional Configuration Key + +/// Configuration key for optional values without defaults +/// +/// Use `OptionalConfigKey` when a configuration value has no sensible default +/// and should be `nil` when not provided by the user. The `read()` method +/// will return an optional value. +/// +/// Example: +/// ```swift +/// let apiKey = OptionalConfigKey(base: "api.key") +/// // read(apiKey) returns String? +/// ``` +public struct OptionalConfigKey: ConfigurationKey, Sendable { + private let baseKey: String? + private let styles: [ConfigKeySource: any NamingStyle] + private let explicitKeys: [ConfigKeySource: String] + + /// Initialize with explicit CLI and ENV keys (no default) + public init(cli: String? = nil, env: String? = nil) { + self.baseKey = nil + self.styles = [:] + var keys: [ConfigKeySource: String] = [:] + if let cli = cli { keys[.commandLine] = cli } + if let env = env { keys[.environment] = env } + self.explicitKeys = keys + } + + /// Initialize from a base key string with naming styles (no default) + /// - Parameters: + /// - base: Base key string (e.g., "cloudkit.key_id") + /// - styles: Dictionary mapping sources to naming styles + public init( + base: String, + styles: [ConfigKeySource: any NamingStyle] + ) { + self.baseKey = base + self.styles = styles + self.explicitKeys = [:] + } + + /// Convenience initializer with standard naming conventions (no default) + /// - Parameters: + /// - base: Base key string (e.g., "cloudkit.key_id") + /// - envPrefix: Prefix for environment variable (defaults to nil) + public init(_ base: String, envPrefix: String? = nil) { + self.baseKey = base + self.styles = [ + .commandLine: StandardNamingStyle.dotSeparated, + .environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix), + ] + self.explicitKeys = [:] + } + + public func key(for source: ConfigKeySource) -> String? { + // Check for explicit key first + if let explicit = explicitKeys[source] { + return explicit + } + + // Generate from base key and style + guard let base = baseKey, let style = styles[source] else { + return nil + } + + return style.transform(base) + } +} + +extension OptionalConfigKey: CustomDebugStringConvertible { + public var debugDescription: String { + let cliKey = key(for: .commandLine) ?? "nil" + let envKey = key(for: .environment) ?? "nil" + return "OptionalConfigKey(cli: \(cliKey), env: \(envKey))" + } +} + +// MARK: - Convenience Initializers for BUSHEL Prefix + +extension OptionalConfigKey { + /// Convenience initializer for keys with BUSHEL prefix + /// - Parameter base: Base key string (e.g., "sync.min_interval") + public init(bushelPrefixed base: String) { + self.init(base, envPrefix: "BUSHEL") + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift new file mode 100644 index 00000000..e00bbff9 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift @@ -0,0 +1,299 @@ +// +// MockCloudKitServiceTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import BushelFoundation +import Foundation +import MistKit +import Testing + +@testable import BushelCloudKit + +// MARK: - Mock CloudKit Service Tests + +@Suite("Mock CloudKit Service Tests") +internal struct MockCloudKitServiceTests { + @Test("Query returns empty array initially") + internal func testQueryEmptyInitially() async throws { + let service = MockCloudKitService() + + let results = try await service.queryRecords(recordType: "RestoreImage") + + #expect(results.isEmpty) + } + + @Test("Create operation stores record") + internal func testCreateOperationStoresRecord() async throws { + let service = MockCloudKitService() + let record = TestFixtures.sonoma1421 + + let operation = RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: "RestoreImage-\(record.buildNumber)", + fields: record.toCloudKitFields() + ) + + try await service.executeBatchOperations([operation], recordType: "RestoreImage") + + let storedRecords = await service.getStoredRecords(ofType: "RestoreImage") + #expect(storedRecords.count == 1) + #expect(storedRecords[0].recordName == "RestoreImage-23C71") + } + + @Test("ForceReplace operation replaces existing record") + internal func testForceReplaceOperation() async throws { + let service = MockCloudKitService() + let recordName = "RestoreImage-23C71" + + // Create initial record + let initialRecord = TestFixtures.sonoma1421 + let createOp = RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: recordName, + fields: initialRecord.toCloudKitFields() + ) + try await service.executeBatchOperations([createOp], recordType: "RestoreImage") + + // Replace with updated record + let updatedRecord = RestoreImageRecord( + version: "14.2.1 Updated", + buildNumber: "23C71", + releaseDate: initialRecord.releaseDate, + downloadURL: initialRecord.downloadURL, + fileSize: 99_999, + sha256Hash: initialRecord.sha256Hash, + sha1Hash: initialRecord.sha1Hash, + isSigned: false, + isPrerelease: false, + source: "updated-source", + notes: "Updated record", + sourceUpdatedAt: nil + ) + + let replaceOp = RecordOperation( + operationType: .forceReplace, + recordType: "RestoreImage", + recordName: recordName, + fields: updatedRecord.toCloudKitFields() + ) + try await service.executeBatchOperations([replaceOp], recordType: "RestoreImage") + + // Verify only one record exists with updated data + let storedRecords = await service.getStoredRecords(ofType: "RestoreImage") + #expect(storedRecords.count == 1) + + let storedFields = storedRecords[0].fields + if case .int64(let fileSize) = storedFields["fileSize"] { + #expect(fileSize == 99_999) + } else { + Issue.record("fileSize field not found or wrong type") + } + } + + @Test("Delete operation removes record") + internal func testDeleteOperation() async throws { + let service = MockCloudKitService() + let recordName = "RestoreImage-23C71" + + // Create record + let record = TestFixtures.sonoma1421 + let createOp = RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: recordName, + fields: record.toCloudKitFields() + ) + try await service.executeBatchOperations([createOp], recordType: "RestoreImage") + + // Delete record + let deleteOp = RecordOperation( + operationType: .delete, + recordType: "RestoreImage", + recordName: recordName + ) + try await service.executeBatchOperations([deleteOp], recordType: "RestoreImage") + + // Verify record is gone + let storedRecords = await service.getStoredRecords(ofType: "RestoreImage") + #expect(storedRecords.isEmpty) + } + + @Test("Batch operations process multiple records") + internal func testBatchOperations() async throws { + let service = MockCloudKitService() + + let operations = [ + RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: "RestoreImage-23C71", + fields: TestFixtures.sonoma1421.toCloudKitFields() + ), + RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: "RestoreImage-24A5264n", + fields: TestFixtures.sequoia150Beta.toCloudKitFields() + ), + RecordOperation( + operationType: .create, + recordType: "XcodeVersion", + recordName: "XcodeVersion-15C65", + fields: TestFixtures.xcode151.toCloudKitFields() + ), + ] + + try await service.executeBatchOperations( + Array(operations[0...1]), + recordType: "RestoreImage" + ) + try await service.executeBatchOperations([operations[2]], recordType: "XcodeVersion") + + let restoreImages = await service.getStoredRecords(ofType: "RestoreImage") + let xcodeVersions = await service.getStoredRecords(ofType: "XcodeVersion") + + #expect(restoreImages.count == 2) + #expect(xcodeVersions.count == 1) + } + + @Test("Query error throws expected error") + internal func testQueryError() async { + let service = MockCloudKitService() + await service.setShouldFailQuery(true) + await service.setQueryError(MockCloudKitError.networkError) + + do { + _ = try await service.queryRecords(recordType: "RestoreImage") + Issue.record("Expected error to be thrown") + } catch is MockCloudKitError { + // Success - error was thrown as expected + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Modify error throws expected error") + internal func testModifyError() async { + let service = MockCloudKitService() + await service.setShouldFailModify(true) + await service.setModifyError(MockCloudKitError.authenticationFailed) + + let operation = RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: "test", + fields: TestFixtures.sonoma1421.toCloudKitFields() + ) + + do { + try await service.executeBatchOperations([operation], recordType: "RestoreImage") + Issue.record("Expected error to be thrown") + } catch is MockCloudKitError { + // Success - error was thrown as expected + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Operation history tracks all operations") + internal func testOperationHistory() async throws { + let service = MockCloudKitService() + + let batch1 = [ + RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: "test1", + fields: TestFixtures.sonoma1421.toCloudKitFields() + ) + ] + + let batch2 = [ + RecordOperation( + operationType: .create, + recordType: "XcodeVersion", + recordName: "test2", + fields: TestFixtures.xcode151.toCloudKitFields() + ) + ] + + try await service.executeBatchOperations(batch1, recordType: "RestoreImage") + try await service.executeBatchOperations(batch2, recordType: "XcodeVersion") + + let history = await service.getOperationHistory() + #expect(history.count == 2) + #expect(history[0].count == 1) + #expect(history[1].count == 1) + } + + @Test("Clear storage removes all records") + internal func testClearStorage() async throws { + let service = MockCloudKitService() + + // Add some records + let operation = RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: "test", + fields: TestFixtures.sonoma1421.toCloudKitFields() + ) + try await service.executeBatchOperations([operation], recordType: "RestoreImage") + + // Clear storage + await service.clearStorage() + + // Verify everything is cleared + let storedRecords = await service.getStoredRecords(ofType: "RestoreImage") + let history = await service.getOperationHistory() + + #expect(storedRecords.isEmpty) + #expect(history.isEmpty) + } +} + +// MARK: - Helper Extensions for Actor + +extension MockCloudKitService { + internal func setShouldFailQuery(_ value: Bool) { + self.shouldFailQuery = value + } + + internal func setShouldFailModify(_ value: Bool) { + self.shouldFailModify = value + } + + internal func setQueryError(_ error: (any Error)?) { + self.queryError = error + } + + internal func setModifyError(_ error: (any Error)?) { + self.modifyError = error + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift new file mode 100644 index 00000000..08a7a6fd --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift @@ -0,0 +1,113 @@ +// +// PEMValidatorTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import BushelCloudKit + +@Suite("PEM Validation Tests") +internal struct PEMValidatorTests { + @Test("Valid PEM passes validation") + internal func testValidPEM() throws { + let validPEM = """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg + -----END PRIVATE KEY----- + """ + + #expect(throws: Never.self) { + try PEMValidator.validate(validPEM) + } + } + + @Test("Missing header throws error") + internal func testMissingHeader() { + let invalidPEM = """ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg + -----END PRIVATE KEY----- + """ + + #expect(throws: BushelCloudKitError.self) { + try PEMValidator.validate(invalidPEM) + } + } + + @Test("Missing footer throws error") + internal func testMissingFooter() { + let invalidPEM = """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg + """ + + #expect(throws: BushelCloudKitError.self) { + try PEMValidator.validate(invalidPEM) + } + } + + @Test("Empty content throws error") + internal func testEmptyContent() { + let invalidPEM = """ + -----BEGIN PRIVATE KEY----- + -----END PRIVATE KEY----- + """ + + #expect(throws: BushelCloudKitError.self) { + try PEMValidator.validate(invalidPEM) + } + } + + @Test("Invalid base64 throws error") + internal func testInvalidBase64() { + let invalidPEM = """ + -----BEGIN PRIVATE KEY----- + not-valid-base64-content!!! + -----END PRIVATE KEY----- + """ + + #expect(throws: BushelCloudKitError.self) { + try PEMValidator.validate(invalidPEM) + } + } + + @Test("Error messages are helpful") + internal func testErrorMessages() { + let invalidPEM = "invalid" + + do { + try PEMValidator.validate(invalidPEM) + Issue.record("Should have thrown error") + } catch let error as BushelCloudKitError { + let description = error.errorDescription ?? "" + #expect(description.contains("BEGIN PRIVATE KEY")) + #expect(error.recoverySuggestion != nil) + } catch { + Issue.record("Wrong error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift new file mode 100644 index 00000000..c1315836 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift @@ -0,0 +1,664 @@ +// +// ConfigurationLoaderTests.swift +// BushelCloud +// +// Comprehensive tests for ConfigurationLoader +// + +import Configuration +import Foundation +import MistKit +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +/// Comprehensive tests for ConfigurationLoader +/// +/// Tests the configuration loading pipeline from CLI arguments and environment +/// variables through to the final BushelConfiguration structure. +@Suite("ConfigurationLoader Tests") +internal struct ConfigurationLoaderTests { + // MARK: - Boolean Parsing Tests + + @Suite("Boolean Parsing") + internal struct BooleanParsingTests { + @Test("CLI flag presence sets boolean to true") + internal func testCLIFlagPresence() async throws { + // Simulate: bushel-cloud sync --verbose + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: ["sync.verbose"], + env: [:] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == true) + } + + @Test("ENV var 'true' sets boolean to true") + internal func testEnvTrue() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": "true"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == true) + } + + @Test("ENV var '1' sets boolean to true") + internal func testEnvOne() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": "1"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == true) + } + + @Test( + "ENV var 'yes' (case-insensitive) sets boolean to true", + arguments: ["yes", "YES", "Yes", "yEs"] + ) + internal func testEnvYes(value: String) async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": value] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == true) + } + + @Test("ENV var 'false' sets boolean to false") + internal func testEnvFalse() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": "false"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == false) + } + + @Test("ENV var '0' sets boolean to false") + internal func testEnvZero() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": "0"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == false) + } + + @Test("ENV var 'no' sets boolean to false") + internal func testEnvNo() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": "no"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == false) + } + + @Test("Empty ENV var uses default value") + internal func testEnvEmpty() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": ""] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == false) // Default + } + + @Test("Invalid ENV var value uses default") + internal func testEnvInvalid() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": "maybe"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == false) // Default + } + + @Test("ENV var with whitespace is trimmed and parsed") + internal func testEnvWhitespace() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": " true "] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == true) + } + } + + // MARK: - Source Precedence Tests + + @Suite("Source Precedence") + internal struct SourcePrecedenceTests { + @Test("CLI flag overrides ENV false") + internal func testCLIOverridesEnvFalse() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: ["sync.verbose"], + env: ["BUSHEL_SYNC_VERBOSE": "false"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == true) // CLI wins + } + + @Test("Absence of CLI flag respects ENV true") + internal func testNoCLIRespectsEnvTrue() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_VERBOSE": "true"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.verbose == true) // ENV used + } + } + + // MARK: - String Parsing Tests + + @Suite("String Parsing") + internal struct StringParsingTests { + @Test("String value from CLI arguments") + internal func testStringFromCLI() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: ["cloudkit.container_id=iCloud.com.test.App"], + env: [:] + ) + + let config = try await loader.loadConfiguration() + #expect(config.cloudKit?.containerID == "iCloud.com.test.App") + } + + @Test("String value from environment variable") + internal func testStringFromEnv() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["CLOUDKIT_CONTAINER_ID": "iCloud.com.env.App"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.cloudKit?.containerID == "iCloud.com.env.App") + } + + @Test("CLI string overrides ENV string") + internal func testStringCLIPrecedence() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: ["cloudkit.container_id=iCloud.com.cli.App"], + env: ["CLOUDKIT_CONTAINER_ID": "iCloud.com.env.App"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.cloudKit?.containerID == "iCloud.com.cli.App") + } + + @Test("String uses default when not provided") + internal func testStringDefault() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [:] + ) + + let config = try await loader.loadConfiguration() + #expect(config.cloudKit?.containerID == "iCloud.com.brightdigit.Bushel") + } + } + + // MARK: - Integer Parsing Tests + + @Suite("Integer Parsing") + internal struct IntegerParsingTests { + @Test("Valid integer from CLI") + internal func testValidIntFromCLI() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: ["sync.min_interval=3600"], + env: [:] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.minInterval == 3_600) + } + + @Test("Valid integer from ENV") + internal func testValidIntFromEnv() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_MIN_INTERVAL": "7200"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.minInterval == 7_200) + } + + @Test("Invalid integer string returns nil") + internal func testInvalidInt() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_MIN_INTERVAL": "not-a-number"] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.minInterval == nil) + } + + @Test("Empty string for integer returns nil") + internal func testEmptyInt() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["BUSHEL_SYNC_MIN_INTERVAL": ""] + ) + + let config = try await loader.loadConfiguration() + #expect(config.sync?.minInterval == nil) + } + } + + // MARK: - Double Parsing Tests + + @Suite("Double Parsing") + internal struct DoubleParsingTests { + @Test("Valid double from CLI") + internal func testValidDoubleFromCLI() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: ["fetch.interval.appledb_dev=3600.5"], + env: [:] + ) + + let config = try await loader.loadConfiguration() + let interval = config.fetch?.perSourceIntervals["appledb.dev"] + #expect(interval == 3_600.5) + } + + @Test("Invalid double string returns nil") + internal func testInvalidDouble() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: ["FETCH_INTERVAL_APPLEDB_DEV": "invalid"] + ) + + let config = try await loader.loadConfiguration() + let interval = config.fetch?.perSourceIntervals["appledb.dev"] + #expect(interval == nil) + } + } + + // MARK: - CloudKit Configuration Tests + + @Suite("CloudKit Configuration") + internal struct CloudKitConfigurationTests { + @Test("Missing CloudKit key ID throws error") + internal func testMissingKeyID() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", + // Missing CLOUDKIT_KEY_ID + ] + ) + + let config = try await loader.loadConfiguration() + + // Should fail validation + #expect(throws: ConfigurationError.self) { + try config.validated() + } + } + + @Test("Missing CloudKit private key path throws error") + internal func testMissingPrivateKeyPath() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + // Missing CLOUDKIT_PRIVATE_KEY_PATH + ] + ) + + let config = try await loader.loadConfiguration() + + #expect(throws: ConfigurationError.self) { + try config.validated() + } + } + + @Test("All CloudKit fields present passes validation") + internal func testAllCloudKitFieldsPresent() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", + ] + ) + + let config = try await loader.loadConfiguration() + let validated = try config.validated() + + #expect(validated.cloudKit.containerID == "iCloud.com.test.App") + #expect(validated.cloudKit.keyID == "test-key-id") + #expect(validated.cloudKit.privateKeyPath == "/path/to/key.pem") + } + + @Test("CloudKit privateKey from environment variable") + internal func testPrivateKeyFromEnv() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_PRIVATE_KEY": + "-----BEGIN PRIVATE KEY-----\nMIGH...\n-----END PRIVATE KEY-----", + ] + ) + + let config = try await loader.loadConfiguration() + let validated = try config.validated() + + #expect(validated.cloudKit.privateKey != nil) + #expect(validated.cloudKit.privateKey?.contains("BEGIN PRIVATE KEY") == true) + } + + @Test( + "CloudKit environment from environment variable", + arguments: ["development", "production"] + ) + internal func testEnvironmentFromEnv(environment: String) async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", + "CLOUDKIT_ENVIRONMENT": environment, + ] + ) + + let config = try await loader.loadConfiguration() + let validated = try config.validated() + + #expect(validated.cloudKit.environment.rawValue == environment) + } + + @Test("Invalid CloudKit environment throws error") + internal func testInvalidEnvironment() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", + "CLOUDKIT_ENVIRONMENT": "staging", // Invalid + ] + ) + + let config = try await loader.loadConfiguration() + + #expect(throws: ConfigurationError.self) { + try config.validated() + } + } + + @Test("Missing both privateKey and privateKeyPath throws error") + internal func testMissingBothCredentials() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + // Missing both CLOUDKIT_PRIVATE_KEY and CLOUDKIT_PRIVATE_KEY_PATH + ] + ) + + let config = try await loader.loadConfiguration() + + #expect(throws: ConfigurationError.self) { + try config.validated() + } + } + + @Test("privateKey takes precedence over privateKeyPath when both are set") + internal func testPrivateKeyPrecedence() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_PRIVATE_KEY": + "-----BEGIN PRIVATE KEY-----\nfrom-env\n-----END PRIVATE KEY-----", + "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", + ] + ) + + let config = try await loader.loadConfiguration() + let validated = try config.validated() + + // Both should be set in validated config + #expect(validated.cloudKit.privateKey != nil) + #expect(!validated.cloudKit.privateKeyPath.isEmpty) + // SyncEngine will prefer privateKey when initializing + } + + @Test("Empty CLOUDKIT_PRIVATE_KEY is treated as absent") + internal func testEmptyPrivateKeyIsAbsent() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_PRIVATE_KEY": " ", // Whitespace only + "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", + ] + ) + + let config = try await loader.loadConfiguration() + let validated = try config.validated() + + // Should use privateKeyPath since privateKey is effectively empty + #expect(validated.cloudKit.privateKey == nil) + #expect(!validated.cloudKit.privateKeyPath.isEmpty) + } + + @Test("Environment parsing is case-insensitive") + internal func testEnvironmentCaseInsensitive() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", + "CLOUDKIT_ENVIRONMENT": "Production", // Mixed case + ] + ) + + let config = try await loader.loadConfiguration() + let validated = try config.validated() + + #expect(validated.cloudKit.environment == .production) + } + + @Test("All CloudKit fields present with privateKey passes validation") + internal func testAllCloudKitFieldsWithPrivateKey() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_PRIVATE_KEY": + "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "CLOUDKIT_ENVIRONMENT": "production", + ] + ) + + let config = try await loader.loadConfiguration() + let validated = try config.validated() + + #expect(validated.cloudKit.containerID == "iCloud.com.test.App") + #expect(validated.cloudKit.keyID == "test-key-id") + #expect(validated.cloudKit.privateKey != nil) + #expect(validated.cloudKit.environment == .production) + } + } + + // MARK: - Command Configuration Tests + + @Suite("Command Configurations") + internal struct CommandConfigurationTests { + @Test("Sync configuration uses defaults when not provided") + internal func testSyncDefaults() async throws { + let loader = ConfigurationLoaderTests.createLoader(cliArgs: [], env: [:]) + + let config = try await loader.loadConfiguration() + + #expect(config.sync?.dryRun == false) + #expect(config.sync?.verbose == false) + #expect(config.sync?.force == false) + #expect(config.sync?.minInterval == nil) + } + + @Test("Export configuration from CLI arguments") + internal func testExportFromCLI() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [ + "export.output=/tmp/export.json", + "export.pretty", + "export.signed_only", + ], + env: [:] + ) + + let config = try await loader.loadConfiguration() + + #expect(config.export?.output == "/tmp/export.json") + #expect(config.export?.pretty == true) + #expect(config.export?.signedOnly == true) + } + + @Test("Multiple command configurations coexist") + internal func testMultipleCommandConfigs() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [ + "sync.verbose", + "export.pretty", + "list.restore_images", + ], + env: [:] + ) + + let config = try await loader.loadConfiguration() + + #expect(config.sync?.verbose == true) + #expect(config.export?.pretty == true) + #expect(config.list?.restoreImages == true) + } + } + + // MARK: - Integration Tests + + @Suite("Integration Tests") + internal struct IntegrationTests { + @Test("Complete sync configuration from multiple sources") + internal func testCompleteSyncConfig() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [ + "sync.verbose", + "sync.dry_run", + "sync.min_interval=3600", + ], + env: [ + "BUSHEL_SYNC_NO_BETAS": "true", + "BUSHEL_SYNC_SOURCE": "ipsw.me", + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", + "CLOUDKIT_KEY_ID": "test-key", + "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", + ] + ) + + let config = try await loader.loadConfiguration() + + // From CLI + #expect(config.sync?.verbose == true) + #expect(config.sync?.dryRun == true) + #expect(config.sync?.minInterval == 3_600) + + // From ENV + #expect(config.sync?.noBetas == true) + #expect(config.sync?.source == "ipsw.me") + + // CloudKit from ENV + #expect(config.cloudKit?.containerID == "iCloud.com.test.App") + } + + @Test("Fetch configuration with per-source intervals") + internal func testFetchPerSourceIntervals() async throws { + let loader = ConfigurationLoaderTests.createLoader( + cliArgs: [], + env: [ + "FETCH_INTERVAL_APPLEDB_DEV": "7200", + "FETCH_INTERVAL_IPSW_ME": "10800", + ] + ) + + let config = try await loader.loadConfiguration() + + #expect(config.fetch?.perSourceIntervals["appledb.dev"] == 7_200) + #expect(config.fetch?.perSourceIntervals["ipsw.me"] == 10_800) + } + } + + // MARK: - Test Utilities + + /// Create a ConfigurationLoader with simulated CLI args and environment variables + /// + /// - Parameters: + /// - cliArgs: Simulated CLI arguments (format: "key=value" or "key" for flags) + /// - env: Simulated environment variables + /// - Returns: ConfigurationLoader with controlled inputs + private static func createLoader( + cliArgs: [String], + env: [String: String] + ) -> ConfigurationLoader { + // Parse CLI args: "key=value" or "key" for flags + var cliValues: [AbsoluteConfigKey: ConfigValue] = [:] + for arg in cliArgs { + if arg.contains("=") { + let parts = arg.split(separator: "=", maxSplits: 1) + if parts.count == 2 { + let key = AbsoluteConfigKey(stringLiteral: String(parts[0])) + cliValues[key] = .init(.string(String(parts[1])), isSecret: false) + } + } else { + // Flag presence (boolean) + let key = AbsoluteConfigKey(stringLiteral: arg) + cliValues[key] = .init(.string("true"), isSecret: false) + } + } + + // ENV vars as-is + var envValues: [AbsoluteConfigKey: ConfigValue] = [:] + for (key, value) in env { + let configKey = AbsoluteConfigKey(stringLiteral: key) + envValues[configKey] = .init(.string(value), isSecret: false) + } + + let providers: [any ConfigProvider] = [ + InMemoryProvider(values: cliValues), // Priority 1: CLI + InMemoryProvider(values: envValues), // Priority 2: ENV + ] + + let configReader = ConfigReader(providers: providers) + return ConfigurationLoader(configReader: configReader) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/FetchConfigurationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/FetchConfigurationTests.swift new file mode 100644 index 00000000..578751cf --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/FetchConfigurationTests.swift @@ -0,0 +1,253 @@ +// +// FetchConfigurationTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// + +import Foundation +import Testing + +@testable import BushelFoundation + +@Suite("FetchConfiguration Logic") +internal struct FetchConfigurationTests { + // MARK: - Minimum Interval Tests + + @Test("Per-source interval overrides global") + internal func testPerSourceOverridesGlobal() { + let config = FetchConfiguration( + globalMinimumFetchInterval: 3_600, // 1 hour + perSourceIntervals: ["appledb.dev": 7_200] // 2 hours + ) + + #expect(config.minimumInterval(for: "appledb.dev") == 7_200) + #expect(config.minimumInterval(for: "ipsw.me") == 3_600) + } + + @Test("Global interval used when no per-source interval") + internal func testGlobalInterval() { + let config = FetchConfiguration( + globalMinimumFetchInterval: 5_400 // 1.5 hours + ) + + #expect(config.minimumInterval(for: "appledb.dev") == 5_400) + #expect(config.minimumInterval(for: "unknown.source") == 5_400) + } + + @Test("Default intervals used when enabled") + internal func testDefaultIntervals() { + let config = FetchConfiguration(useDefaults: true) + + // 6 hours + #expect(config.minimumInterval(for: "appledb.dev") == TimeInterval(6 * 3_600)) + // 12 hours + #expect(config.minimumInterval(for: "ipsw.me") == TimeInterval(12 * 3_600)) + // 1 hour + #expect(config.minimumInterval(for: "mesu.apple.com") == TimeInterval(1 * 3_600)) + // 12 hours + #expect(config.minimumInterval(for: "xcodereleases.com") == TimeInterval(12 * 3_600)) + } + + @Test("Default intervals not used when disabled") + internal func testDefaultIntervalsDisabled() { + let config = FetchConfiguration(useDefaults: false) + + #expect(config.minimumInterval(for: "appledb.dev") == nil) + #expect(config.minimumInterval(for: "ipsw.me") == nil) + } + + @Test("Per-source overrides defaults") + internal func testPerSourceOverridesDefaults() { + let config = FetchConfiguration( + perSourceIntervals: ["appledb.dev": 1_800], // 30 minutes + useDefaults: true + ) + + // Per-source should override default + #expect(config.minimumInterval(for: "appledb.dev") == 1_800) + // Default should be used for other sources + #expect(config.minimumInterval(for: "ipsw.me") == TimeInterval(12 * 3_600)) + } + + @Test("Global overrides defaults") + internal func testGlobalOverridesDefaults() { + let config = FetchConfiguration( + globalMinimumFetchInterval: 7_200, // 2 hours + useDefaults: true + ) + + // Global should override defaults + #expect(config.minimumInterval(for: "appledb.dev") == 7_200) + #expect(config.minimumInterval(for: "ipsw.me") == 7_200) + } + + @Test("Unknown source with no configuration returns nil") + internal func testUnknownSourceNoConfig() { + let config = FetchConfiguration(useDefaults: false) + + #expect(config.minimumInterval(for: "unknown.source") == nil) + } + + // MARK: - Should Fetch Tests + + @Test("Should fetch when force is true") + internal func testForceFetch() { + let config = FetchConfiguration(globalMinimumFetchInterval: 3_600) + let lastFetch = Date(timeIntervalSinceNow: -1_800) // 30 min ago (less than 1 hour) + + let result = config.shouldFetch( + source: "ipsw.me", + lastFetchedAt: lastFetch, + force: true + ) + + #expect(result == true) + } + + @Test("Should fetch when never fetched before") + internal func testNeverFetchedBefore() { + let config = FetchConfiguration(globalMinimumFetchInterval: 3_600) + + let result = config.shouldFetch( + source: "ipsw.me", + lastFetchedAt: nil, + force: false + ) + + #expect(result == true) + } + + @Test("Should not fetch when interval not elapsed") + internal func testThrottling() { + let config = FetchConfiguration(globalMinimumFetchInterval: 3_600) // 1 hour + let lastFetch = Date(timeIntervalSinceNow: -1_800) // 30 min ago (less than 1 hour) + + let result = config.shouldFetch( + source: "ipsw.me", + lastFetchedAt: lastFetch, + force: false + ) + + #expect(result == false) + } + + @Test("Should fetch when interval has elapsed") + internal func testFetchWhenIntervalElapsed() { + let config = FetchConfiguration(globalMinimumFetchInterval: 3_600) // 1 hour + let lastFetch = Date(timeIntervalSinceNow: -7_200) // 2 hours ago (more than 1 hour) + + let result = config.shouldFetch( + source: "ipsw.me", + lastFetchedAt: lastFetch, + force: false + ) + + #expect(result == true) + } + + @Test("Should fetch when no interval configured") + internal func testNoIntervalConfigured() { + let config = FetchConfiguration(useDefaults: false) + let lastFetch = Date(timeIntervalSinceNow: -60) // 1 minute ago + + let result = config.shouldFetch( + source: "unknown.source", + lastFetchedAt: lastFetch, + force: false + ) + + #expect(result == true) + } + + @Test("Should respect per-source intervals in shouldFetch") + internal func testPerSourceIntervalInShouldFetch() { + let config = FetchConfiguration( + globalMinimumFetchInterval: 3_600, // 1 hour + perSourceIntervals: ["appledb.dev": 7_200] // 2 hours + ) + + // appledb.dev needs 2 hours, but only 1.5 hours passed + let lastFetch1 = Date(timeIntervalSinceNow: -5_400) // 1.5 hours ago + #expect(config.shouldFetch(source: "appledb.dev", lastFetchedAt: lastFetch1) == false) + + // ipsw.me needs 1 hour (global), 1.5 hours passed + let lastFetch2 = Date(timeIntervalSinceNow: -5_400) // 1.5 hours ago + #expect(config.shouldFetch(source: "ipsw.me", lastFetchedAt: lastFetch2) == true) + } + + // MARK: - Default Intervals Tests + + @Test("Default intervals contain expected sources") + internal func testDefaultIntervalsExist() { + let defaults = FetchConfiguration.defaultIntervals + + #expect(defaults["appledb.dev"] != nil) + #expect(defaults["ipsw.me"] != nil) + #expect(defaults["mesu.apple.com"] != nil) + #expect(defaults["mrmacintosh.com"] != nil) + #expect(defaults["xcodereleases.com"] != nil) + #expect(defaults["swiftversion.net"] != nil) + } + + @Test("Default intervals have reasonable values") + internal func testDefaultIntervalValues() { + let defaults = FetchConfiguration.defaultIntervals + + // All intervals should be positive + for (_, interval) in defaults { + #expect(interval > 0) + } + + // MESU should have shortest interval (signing changes frequently) + #expect(defaults["mesu.apple.com"] == TimeInterval(1 * 3_600)) + + // AppleDB should be moderate (6 hours) + #expect(defaults["appledb.dev"] == TimeInterval(6 * 3_600)) + + // Most others should be 12 hours or more + #expect(defaults["ipsw.me"] == TimeInterval(12 * 3_600)) + #expect(defaults["mrmacintosh.com"] == TimeInterval(12 * 3_600)) + } + + // MARK: - Codable Tests + + @Test("Configuration is encodable and decodable") + internal func testCodable() throws { + let original = FetchConfiguration( + globalMinimumFetchInterval: 5_400, + perSourceIntervals: ["appledb.dev": 7_200, "ipsw.me": 10_800], + useDefaults: false + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(original) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(FetchConfiguration.self, from: data) + + #expect(decoded.globalMinimumFetchInterval == original.globalMinimumFetchInterval) + #expect(decoded.perSourceIntervals == original.perSourceIntervals) + #expect(decoded.useDefaults == original.useDefaults) + } + + // MARK: - Edge Cases + + @Test("Zero interval allows immediate refetch") + internal func testZeroInterval() { + let config = FetchConfiguration(globalMinimumFetchInterval: 0) + let lastFetch = Date(timeIntervalSinceNow: -1) // 1 second ago + + #expect(config.shouldFetch(source: "ipsw.me", lastFetchedAt: lastFetch) == true) + } + + @Test("Boundary condition: exactly at interval") + internal func testExactlyAtInterval() { + let config = FetchConfiguration(globalMinimumFetchInterval: 3_600) // 1 hour + let lastFetch = Date(timeIntervalSinceNow: -3_600) // exactly 1 hour ago + + // Should allow fetch when time >= interval + #expect(config.shouldFetch(source: "ipsw.me", lastFetchedAt: lastFetch) == true) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockAppleDBFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockAppleDBFetcherTests.swift new file mode 100644 index 00000000..e8bae23b --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockAppleDBFetcherTests.swift @@ -0,0 +1,68 @@ +// +// MockAppleDBFetcherTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelFoundation + +// MARK: - Mock AppleDB Fetcher Tests + +@Suite("Mock AppleDB Fetcher Tests") +internal struct MockAppleDBFetcherTests { + @Test("Successful fetch returns records") + internal func testSuccessfulFetch() async throws { + let expectedRecords = [TestFixtures.sonoma1421Appledb] + let fetcher = MockAppleDBFetcher(recordsToReturn: expectedRecords) + + let result = try await fetcher.fetch() + + #expect(result.count == 1) + #expect(result[0].source == "appledb.dev") + } + + @Test("Server error throws expected error") + internal func testServerError() async { + let expectedError = MockFetcherError.serverError(code: 500) + let fetcher = MockAppleDBFetcher(errorToThrow: expectedError) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected error to be thrown") + } catch let error as MockFetcherError { + if case .serverError(let code) = error { + #expect(code == 500) + } else { + Issue.record("Wrong error type thrown") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockIPSWFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockIPSWFetcherTests.swift new file mode 100644 index 00000000..c3e66c73 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockIPSWFetcherTests.swift @@ -0,0 +1,78 @@ +// +// MockIPSWFetcherTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelFoundation + +// MARK: - Mock IPSW Fetcher Tests + +@Suite("Mock IPSW Fetcher Tests") +internal struct MockIPSWFetcherTests { + @Test("Successful fetch returns records") + internal func testSuccessfulFetch() async throws { + let expectedRecords = [TestFixtures.sonoma1421, TestFixtures.sequoia150Beta] + let fetcher = MockIPSWFetcher(recordsToReturn: expectedRecords) + + let result = try await fetcher.fetch() + + #expect(result.count == 2) + #expect(result[0].buildNumber == "23C71") + #expect(result[1].buildNumber == "24A5264n") + } + + @Test("Empty fetch returns empty array") + internal func testEmptyFetch() async throws { + let fetcher = MockIPSWFetcher(recordsToReturn: []) + + let result = try await fetcher.fetch() + + #expect(result.isEmpty) + } + + @Test("Network error throws expected error") + internal func testNetworkError() async { + let expectedError = MockFetcherError.networkError("Connection timeout") + let fetcher = MockIPSWFetcher(errorToThrow: expectedError) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected error to be thrown") + } catch let error as MockFetcherError { + if case .networkError(let message) = error { + #expect(message == "Connection timeout") + } else { + Issue.record("Wrong error type thrown") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockMESUFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockMESUFetcherTests.swift new file mode 100644 index 00000000..2bf31de9 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockMESUFetcherTests.swift @@ -0,0 +1,74 @@ +// +// MockMESUFetcherTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelFoundation + +// MARK: - Mock MESU Fetcher Tests + +@Suite("Mock MESU Fetcher Tests") +internal struct MockMESUFetcherTests { + @Test("Successful fetch returns single record") + internal func testSuccessfulFetch() async throws { + let expectedRecord = TestFixtures.sonoma1421Mesu + let fetcher = MockMESUFetcher(recordToReturn: expectedRecord) + + let result = try await fetcher.fetch() + + #expect(result != nil) + #expect(result?.source == "mesu.apple.com") + #expect(result?.buildNumber == "23C71") + } + + @Test("Empty fetch returns nil") + internal func testEmptyFetch() async throws { + let fetcher = MockMESUFetcher(recordToReturn: nil) + + let result = try await fetcher.fetch() + + #expect(result == nil) + } + + @Test("Invalid response error") + internal func testInvalidResponse() async { + let expectedError = MockFetcherError.invalidResponse + let fetcher = MockMESUFetcher(errorToThrow: expectedError) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected error to be thrown") + } catch is MockFetcherError { + // Success - error was thrown as expected + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockSwiftVersionFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockSwiftVersionFetcherTests.swift new file mode 100644 index 00000000..54e22b52 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockSwiftVersionFetcherTests.swift @@ -0,0 +1,65 @@ +// +// MockSwiftVersionFetcherTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelFoundation + +// MARK: - Mock Swift Version Fetcher Tests + +@Suite("Mock Swift Version Fetcher Tests") +internal struct MockSwiftVersionFetcherTests { + @Test("Successful fetch returns records") + internal func testSuccessfulFetch() async throws { + let expectedRecords = [TestFixtures.swift592, TestFixtures.swift60Snapshot] + let fetcher = MockSwiftVersionFetcher(recordsToReturn: expectedRecords) + + let result = try await fetcher.fetch() + + #expect(result.count == 2) + #expect(result[0].version == "5.9.2") + #expect(result[1].version == "6.0") + } + + @Test("Timeout error") + internal func testTimeoutError() async { + let expectedError = MockFetcherError.timeout + let fetcher = MockSwiftVersionFetcher(errorToThrow: expectedError) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected error to be thrown") + } catch is MockFetcherError { + // Success - error was thrown as expected + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockXcodeReleasesFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockXcodeReleasesFetcherTests.swift new file mode 100644 index 00000000..187f673f --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockXcodeReleasesFetcherTests.swift @@ -0,0 +1,65 @@ +// +// MockXcodeReleasesFetcherTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelFoundation + +// MARK: - Mock Xcode Releases Fetcher Tests + +@Suite("Mock Xcode Releases Fetcher Tests") +internal struct MockXcodeReleasesFetcherTests { + @Test("Successful fetch returns records") + internal func testSuccessfulFetch() async throws { + let expectedRecords = [TestFixtures.xcode151, TestFixtures.xcode160Beta] + let fetcher = MockXcodeReleasesFetcher(recordsToReturn: expectedRecords) + + let result = try await fetcher.fetch() + + #expect(result.count == 2) + #expect(result[0].version == "15.1") + #expect(result[1].version == "16.0 Beta 1") + } + + @Test("Authentication error") + internal func testAuthenticationError() async { + let expectedError = MockFetcherError.authenticationFailed + let fetcher = MockXcodeReleasesFetcher(errorToThrow: expectedError) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected error to be thrown") + } catch is MockFetcherError { + // Success - error was thrown as expected + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageDeduplicationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageDeduplicationTests.swift new file mode 100644 index 00000000..d0be214b --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageDeduplicationTests.swift @@ -0,0 +1,101 @@ +// +// RestoreImageDeduplicationTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +// MARK: - Suite 1: RestoreImage Deduplication Tests + +@Suite("RestoreImage Deduplication") +internal struct RestoreImageDeduplicationTests { + internal let pipeline = DataSourcePipeline() + + @Test("Empty array returns empty") + internal func testDeduplicateEmpty() { + let result = pipeline.deduplicateRestoreImages([]) + #expect(result.isEmpty) + } + + @Test("Single record returns unchanged") + internal func testDeduplicateSingle() { + let input = [TestFixtures.sonoma1421] + let result = pipeline.deduplicateRestoreImages(input) + + #expect(result.count == 1) + #expect(result[0].buildNumber == "23C71") + } + + @Test("Different builds all preserved") + internal func testDeduplicateDifferentBuilds() { + let input = [ + TestFixtures.sonoma1421, + TestFixtures.sequoia151, + TestFixtures.sonoma140, + ] + let result = pipeline.deduplicateRestoreImages(input) + + #expect(result.count == 3) + // Should be sorted by releaseDate descending + #expect(result[0].buildNumber == "24B83") // sequoia151 (Nov 2024) + #expect(result[1].buildNumber == "23C71") // sonoma1421 (Dec 2023) + #expect(result[2].buildNumber == "23A344") // sonoma140 (Sep 2023) + } + + @Test("Duplicate builds merged") + internal func testDeduplicateDuplicateBuilds() { + let input = [ + TestFixtures.sonoma1421, + TestFixtures.sonoma1421Mesu, + TestFixtures.sonoma1421Appledb, + ] + let result = pipeline.deduplicateRestoreImages(input) + + // Should have only 1 record after merging + #expect(result.count == 1) + #expect(result[0].buildNumber == "23C71") + } + + @Test("Results sorted by release date descending") + internal func testSortingByReleaseDateDescending() { + let input = [ + TestFixtures.sonoma140, // Oldest: Sep 2023 + TestFixtures.sonoma1421, // Middle: Dec 2023 + TestFixtures.sequoia151, // Newest: Nov 2024 + ] + let result = pipeline.deduplicateRestoreImages(input) + + #expect(result.count == 3) + // Verify descending order + #expect(result[0].releaseDate > result[1].releaseDate) + #expect(result[1].releaseDate > result[2].releaseDate) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageMergeTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageMergeTests.swift new file mode 100644 index 00000000..c7432286 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageMergeTests.swift @@ -0,0 +1,365 @@ +// +// RestoreImageMergeTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +// MARK: - Suite 2: RestoreImage Merge Tests + +@Suite("RestoreImage Merge Logic") +internal struct RestoreImageMergeTests { + internal let pipeline = DataSourcePipeline() + + // MARK: Backfill Tests + + @Test("Backfill SHA256 hash from second record") + internal func testBackfillSHA256() { + let incomplete = TestFixtures.sonoma1421Incomplete + let complete = TestFixtures.sonoma1421 + + let merged = pipeline.mergeRestoreImages(incomplete, complete) + + #expect(merged.sha256Hash == complete.sha256Hash) + #expect(!merged.sha256Hash.isEmpty) + } + + @Test("Backfill SHA1 hash from second record") + internal func testBackfillSHA1() { + let incomplete = TestFixtures.sonoma1421Incomplete + let complete = TestFixtures.sonoma1421 + + let merged = pipeline.mergeRestoreImages(incomplete, complete) + + #expect(merged.sha1Hash == complete.sha1Hash) + #expect(!merged.sha1Hash.isEmpty) + } + + @Test("Backfill file size from second record") + internal func testBackfillFileSize() { + let incomplete = TestFixtures.sonoma1421Incomplete + let complete = TestFixtures.sonoma1421 + + let merged = pipeline.mergeRestoreImages(incomplete, complete) + + #expect(merged.fileSize == complete.fileSize) + #expect(merged.fileSize > 0) + } + + // MARK: MESU Authority Tests + + @Test("MESU first takes precedence for isSigned") + internal func testMESUFirstAuthoritative() { + let mesu = TestFixtures.sonoma1421Mesu // isSigned=false + let ipsw = TestFixtures.sonoma1421 // isSigned=true + + let merged = pipeline.mergeRestoreImages(mesu, ipsw) + + // MESU authority wins + #expect(merged.isSigned == false) + } + + @Test("MESU second takes precedence for isSigned") + internal func testMESUSecondAuthoritative() { + let ipsw = TestFixtures.sonoma1421 // isSigned=true + let mesu = TestFixtures.sonoma1421Mesu // isSigned=false + + let merged = pipeline.mergeRestoreImages(ipsw, mesu) + + // MESU authority wins regardless of order + #expect(merged.isSigned == false) + } + + @Test("MESU authority overrides newer timestamp") + internal func testMESUOverridesNewerTimestamp() { + let appledb = TestFixtures.sonoma1421Appledb // newer timestamp, isSigned=true + let mesu = TestFixtures.sonoma1421Mesu // MESU, isSigned=false + + let merged = pipeline.mergeRestoreImages(appledb, mesu) + + // MESU authority trumps recency + #expect(merged.isSigned == false) + } + + @Test("MESU with nil isSigned does not override") + internal func testMESUWithNilDoesNotOverride() { + // Create MESU record with nil isSigned + let mesuNil = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), + downloadURL: URL(string: "https://mesu.apple.com/assets/macos/23C71/RestoreImage.ipsw")!, + fileSize: 0, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, // nil value + isPrerelease: false, + source: "mesu.apple.com" + ) + let ipsw = TestFixtures.sonoma1421 // isSigned=true + + let merged = pipeline.mergeRestoreImages(mesuNil, ipsw) + + // nil doesn't override + #expect(merged.isSigned == true) + } + + // MARK: Timestamp Comparison Tests + + @Test("Newer sourceUpdatedAt wins when both non-MESU") + internal func testNewerTimestampWins() { + let older = TestFixtures.signedOld // isSigned=true, older timestamp + let newer = TestFixtures.unsignedNewer // isSigned=false, newer timestamp + + let merged = pipeline.mergeRestoreImages(older, newer) + + // Newer timestamp wins + #expect(merged.isSigned == false) + } + + @Test("Older timestamp loses when both non-MESU") + internal func testOlderTimestampLoses() { + let newer = TestFixtures.unsignedNewer // isSigned=false, newer timestamp + let older = TestFixtures.signedOld // isSigned=true, older timestamp + + let merged = pipeline.mergeRestoreImages(newer, older) + + // Newer wins regardless of order + #expect(merged.isSigned == false) + } + + @Test("First with timestamp wins when second has no timestamp") + internal func testFirstTimestampWinsWhenSecondNil() { + let withTimestamp = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: Date(timeIntervalSince1970: 1_705_000_000) + ) + let withoutTimestamp = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: false, + isPrerelease: false, + source: "appledb.dev", + notes: nil, + sourceUpdatedAt: nil + ) + + let merged = pipeline.mergeRestoreImages(withTimestamp, withoutTimestamp) + + // First with timestamp wins + #expect(merged.isSigned == true) + } + + @Test("Second with timestamp wins when first has no timestamp") + internal func testSecondTimestampWinsWhenFirstNil() { + let withoutTimestamp = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: nil + ) + let withTimestamp = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: false, + isPrerelease: false, + source: "appledb.dev", + notes: nil, + sourceUpdatedAt: Date(timeIntervalSince1970: 1_706_000_000) + ) + + let merged = pipeline.mergeRestoreImages(withoutTimestamp, withTimestamp) + + // Second with timestamp wins + #expect(merged.isSigned == false) + } + + @Test("Equal timestamps prefer first value when set") + internal func testEqualTimestampsPreferFirst() { + let sameDate = Date(timeIntervalSince1970: 1_705_000_000) + let first = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: sameDate + ) + let second = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: false, + isPrerelease: false, + source: "appledb.dev", + notes: nil, + sourceUpdatedAt: sameDate + ) + + let merged = pipeline.mergeRestoreImages(first, second) + + // First wins when timestamps equal + #expect(merged.isSigned == true) + } + + // MARK: Nil Handling Tests + + @Test("Both nil timestamps and values disagree prefers false") + internal func testBothNilTimestampsPrefersFalse() { + let signedNilTimestamp = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: nil + ) + let unsignedNilTimestamp = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: false, + isPrerelease: false, + source: "appledb.dev", + notes: nil, + sourceUpdatedAt: nil + ) + + let merged = pipeline.mergeRestoreImages(signedNilTimestamp, unsignedNilTimestamp) + + // Prefer false when both nil and values disagree + #expect(merged.isSigned == false) + } + + @Test("Second isSigned nil preserves first value") + internal func testSecondNilPreservesFirst() { + let signed = TestFixtures.sonoma1421 // isSigned=true + let incomplete = TestFixtures.sonoma1421Incomplete // isSigned=nil + + let merged = pipeline.mergeRestoreImages(signed, incomplete) + + #expect(merged.isSigned == true) + } + + @Test("First isSigned nil uses second value") + internal func testFirstNilUsesSecond() { + let incomplete = TestFixtures.sonoma1421Incomplete // isSigned=nil + let signed = TestFixtures.sonoma1421 // isSigned=true + + let merged = pipeline.mergeRestoreImages(incomplete, signed) + + #expect(merged.isSigned == true) + } + + // MARK: Notes Combination Test + + @Test("Notes combined with semicolon separator") + internal func testNotesCombination() { + let first = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_500_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: "First note" + ) + let second = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 13_500_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: true, + isPrerelease: false, + source: "appledb.dev", + notes: "Second note" + ) + + let merged = pipeline.mergeRestoreImages(first, second) + + #expect(merged.notes == "First note; Second note") + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/SwiftVersionDeduplicationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/SwiftVersionDeduplicationTests.swift new file mode 100644 index 00000000..ca803c67 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/SwiftVersionDeduplicationTests.swift @@ -0,0 +1,84 @@ +// +// SwiftVersionDeduplicationTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +// MARK: - Suite 5: SwiftVersion Deduplication Tests + +@Suite("SwiftVersion Deduplication") +internal struct SwiftVersionDeduplicationTests { + internal let pipeline = DataSourcePipeline() + + @Test("Empty array returns empty") + internal func testDeduplicateEmpty() { + let result = pipeline.deduplicateSwiftVersions([]) + #expect(result.isEmpty) + } + + @Test("Single record returns unchanged") + internal func testDeduplicateSingle() { + let input = [TestFixtures.swift592] + let result = pipeline.deduplicateSwiftVersions(input) + + #expect(result.count == 1) + #expect(result[0].version == "5.9.2") + } + + @Test("Duplicate versions keep first occurrence") + internal func testDuplicateVersionsKeepFirst() { + let input = [ + TestFixtures.swift592, + TestFixtures.swift592Duplicate, + ] + let result = pipeline.deduplicateSwiftVersions(input) + + #expect(result.count == 1) + // Should keep first occurrence + #expect(result[0].version == "5.9.2") + #expect(result[0].notes == "Stable Swift release bundled with Xcode 15.1") + } + + @Test("Results sorted by release date descending") + internal func testSortingByReleaseDateDescending() { + let input = [ + TestFixtures.swift592, // Dec 2023 + TestFixtures.swift61, // Nov 2024 + ] + let result = pipeline.deduplicateSwiftVersions(input) + + #expect(result.count == 2) + // Verify descending order (newer first) + #expect(result[0].version == "6.1") // swift61 + #expect(result[1].version == "5.9.2") // swift592 + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/VirtualBuddyFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/VirtualBuddyFetcherTests.swift new file mode 100644 index 00000000..6a265a2d --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/VirtualBuddyFetcherTests.swift @@ -0,0 +1,756 @@ +// +// VirtualBuddyFetcherTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// All VirtualBuddy tests wrapped in a serialized suite to prevent mock handler conflicts +@Suite( + "VirtualBuddyFetcher Tests", + .serialized, + .enabled( + if: { + #if os(macOS) || os(Linux) + return true + #else + return false + #endif + }() + ) +) +internal struct VirtualBuddyFetcherTests { + // MARK: - Initialization Tests + + @Suite("Initialization") + internal struct InitializationTests { + @Test("Initialize with environment variable") + internal func testInitWithEnvironmentVariable() throws { + // Set environment variable + setenv("VIRTUALBUDDY_API_KEY", "test-api-key-123", 1) + defer { unsetenv("VIRTUALBUDDY_API_KEY") } + + // Initialize fetcher + let fetcher = VirtualBuddyFetcher() + + // Should succeed when environment variable is set + #expect(fetcher != nil) + } + + @Test("Initialize fails without environment variable") + internal func testInitFailsWithoutEnvironmentVariable() throws { + // Ensure environment variable is not set + unsetenv("VIRTUALBUDDY_API_KEY") + + // Initialize fetcher + let fetcher = VirtualBuddyFetcher() + + // Should fail when environment variable is not set + #expect(fetcher == nil) + } + + @Test("Initialize fails with empty environment variable") + internal func testInitFailsWithEmptyEnvironmentVariable() throws { + // Set empty environment variable + setenv("VIRTUALBUDDY_API_KEY", "", 1) + defer { unsetenv("VIRTUALBUDDY_API_KEY") } + + // Initialize fetcher + let fetcher = VirtualBuddyFetcher() + + // Should fail when environment variable is empty + #expect(fetcher == nil) + } + + @Test("Initialize with explicit API key") + internal func testExplicitInit() throws { + // Initialize with explicit API key + let fetcher = VirtualBuddyFetcher(apiKey: "explicit-api-key") + + // Initialization always succeeds (non-failable init) + // Actual functionality validated by fetch operation tests + _ = fetcher + } + + @Test("Initialize with custom dependencies") + internal func testCustomDependencies() throws { + let customDecoder = JSONDecoder() + customDecoder.dateDecodingStrategy = .iso8601 + + let config = URLSessionConfiguration.default + config.protocolClasses = [MockURLProtocol.self] + let customSession = URLSession(configuration: config) + + // Initialize with custom decoder and session + let fetcher = VirtualBuddyFetcher( + apiKey: "test-key", + decoder: customDecoder, + urlSession: customSession + ) + + // Initialization always succeeds (non-failable init) + // Custom dependencies validated by fetch operation tests + _ = fetcher + } + } + + // MARK: - Empty Fetch Tests + + @Suite("Empty Fetch") + internal struct EmptyFetchTests { + @Test("fetch() returns empty array") + internal func testFetchReturnsEmptyArray() async throws { + let fetcher = VirtualBuddyFetcher(apiKey: "test-key") + + let result = try await fetcher.fetch() + + #expect(result.isEmpty) + } + } + + // MARK: - Enrichment Success Tests + + @Suite("Enrichment Success") + internal struct EnrichmentSuccessTests { + @Test("Enrich single signed image") + internal func testEnrichSingleSignedImage() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Configure mock response + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)! + return (response, data) + } + + // Create fetcher with mock session + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + // Test with Sonoma 14.2.1 image + let images = [TestFixtures.sonoma1421] + let enriched = try await fetcher.fetch(existingImages: images) + + // Verify enrichment + #expect(enriched.count == 1) + let enrichedImage = try #require(enriched.first) + + #expect(enrichedImage.buildNumber == "23C71") + #expect(enrichedImage.isSigned == true) + #expect(enrichedImage.source == "tss.virtualbuddy.app") + #expect(enrichedImage.notes == "SUCCESS") + #expect(enrichedImage.sourceUpdatedAt != nil) + } + + @Test("Enrich single unsigned image") + internal func testEnrichSingleUnsignedImage() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Configure mock response for unsigned build + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddyUnsignedResponse.data(using: .utf8)! + return (response, data) + } + + // Create fetcher with mock session + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + // Create test image with matching build number + let testImage = RestoreImageRecord( + version: "15.1", + buildNumber: "24B5024e", + releaseDate: Date(), + downloadURL: URL(string: "https://example.com/test.ipsw")!, + fileSize: 1_000, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, + isPrerelease: true, + source: "test", + notes: nil, + sourceUpdatedAt: nil + ) + + let enriched = try await fetcher.fetch(existingImages: [testImage]) + + // Verify unsigned status + #expect(enriched.count == 1) + let enrichedImage = try #require(enriched.first) + + #expect(enrichedImage.buildNumber == "24B5024e") + #expect(enrichedImage.isSigned == false) + #expect(enrichedImage.source == "tss.virtualbuddy.app") + #expect(enrichedImage.notes == "This device isn't eligible for the requested build.") + } + + @Test("Skip file URL images") + internal func testSkipsFileURLImages() async throws { + let fetcher = VirtualBuddyFetcher(apiKey: "test-key") + + // Create image with file:// URL + let fileImage = RestoreImageRecord( + version: "14.0", + buildNumber: "23A344", + releaseDate: Date(), + downloadURL: URL(string: "file:///path/to/local.ipsw")!, + fileSize: 1_000, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, + isPrerelease: false, + source: "local", + notes: nil, + sourceUpdatedAt: nil + ) + + let result = try await fetcher.fetch(existingImages: [fileImage]) + + // File URLs should pass through unchanged (no API call) + #expect(result.count == 1) + let resultImage = try #require(result.first) + #expect(resultImage.source == "local") // Unchanged + #expect(resultImage.downloadURL.scheme == "file") + } + + @Test("Return empty for empty input") + internal func testEmptyImageList() async throws { + let fetcher = VirtualBuddyFetcher(apiKey: "test-key") + + let result = try await fetcher.fetch(existingImages: []) + + #expect(result.isEmpty) + } + + @Test("Mixed HTTP and file URLs") + internal func testMixedHTTPAndFileURLs() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Configure mock response + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)! + return (response, data) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + // Mix of file and HTTP URLs + let fileImage = RestoreImageRecord( + version: "14.0", + buildNumber: "23A344", + releaseDate: Date(), + downloadURL: URL(string: "file:///local.ipsw")!, + fileSize: 1_000, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, + isPrerelease: false, + source: "local", + notes: nil, + sourceUpdatedAt: nil + ) + + let images = [fileImage, TestFixtures.sonoma1421] + let result = try await fetcher.fetch(existingImages: images) + + #expect(result.count == 2) + // File URL unchanged, HTTP enriched + #expect(result[0].source == "local") + #expect(result[1].source == "tss.virtualbuddy.app") + } + } + + // MARK: - Error Handling Tests + + @Suite("Error Handling") + internal struct ErrorHandlingTests { + @Test("Build number mismatch preserves original") + internal func testBuildNumberMismatch() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Response has wrong build number + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddyBuildMismatchResponse.data(using: .utf8)! + return (response, data) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let images = [TestFixtures.sonoma1421] + let result = try await fetcher.fetch(existingImages: images) + + // Should preserve original when build number doesn't match + #expect(result.count == 1) + let resultImage = try #require(result.first) + #expect(resultImage.source == "ipsw.me") // Original source preserved + #expect(resultImage.buildNumber == "23C71") + } + + @Test("HTTP 400 error preserves original") + internal func testHTTP400Error() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Return HTTP 400 + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 400, + httpVersion: nil, + headerFields: nil + )! + return (response, nil) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let images = [TestFixtures.sonoma1421] + let result = try await fetcher.fetch(existingImages: images) + + // Should preserve original on error + #expect(result.count == 1) + let resultImage = try #require(result.first) + #expect(resultImage.source == "ipsw.me") // Original preserved + } + + @Test("HTTP 429 rate limit error preserves original") + internal func testHTTP429RateLimitError() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Return HTTP 429 + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 429, + httpVersion: nil, + headerFields: nil + )! + return (response, nil) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let images = [TestFixtures.sonoma1421] + let result = try await fetcher.fetch(existingImages: images) + + // Should preserve original on rate limit + #expect(result.count == 1) + #expect(result.first?.source == "ipsw.me") + } + + @Test("HTTP 500 server error preserves original") + internal func testHTTP500Error() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Return HTTP 500 + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + return (response, nil) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let images = [TestFixtures.sonoma1421] + let result = try await fetcher.fetch(existingImages: images) + + // Should preserve original on server error + #expect(result.count == 1) + #expect(result.first?.source == "ipsw.me") + } + + @Test("Network error preserves original") + internal func testNetworkError() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Simulate network error + MockURLProtocol.requestHandler = { _ in + throw NSError( + domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil + ) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let images = [TestFixtures.sonoma1421] + let result = try await fetcher.fetch(existingImages: images) + + // Should preserve original on network error + #expect(result.count == 1) + #expect(result.first?.source == "ipsw.me") + } + + @Test("Invalid JSON response preserves original") + internal func testInvalidJSONResponse() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + // Return invalid JSON + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let invalidJSON = "{ invalid json }".data(using: .utf8)! + return (response, invalidJSON) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let images = [TestFixtures.sonoma1421] + let result = try await fetcher.fetch(existingImages: images) + + // Should preserve original on decode error + #expect(result.count == 1) + #expect(result.first?.source == "ipsw.me") + } + } + + // MARK: - Rate Limiting Tests + + @Suite("Rate Limiting") + internal struct RateLimitingTests { + @Test("No delay for single image") + internal func testNoDelayForSingleImage() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)! + return (response, data) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let startTime = Date() + let images = [TestFixtures.sonoma1421] + _ = try await fetcher.fetch(existingImages: images) + let duration = Date().timeIntervalSince(startTime) + + // Should complete quickly (no 2.5-3.5 second delay) + #expect(duration < 1.0) // Allow some network overhead but no delay + } + + @Test("Delay between multiple images") + internal func testDelayBetweenRequests() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)! + return (response, data) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + // Two HTTP images + let image1 = TestFixtures.sonoma1421 + let image2 = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(), + downloadURL: URL(string: "https://example.com/second.ipsw")!, + fileSize: 1_000, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, + isPrerelease: false, + source: "test", + notes: nil, + sourceUpdatedAt: nil + ) + + let startTime = Date() + _ = try await fetcher.fetch(existingImages: [image1, image2]) + let duration = Date().timeIntervalSince(startTime) + + // Should have delay between requests (2.5-3.5 seconds) + #expect(duration >= 2.0) // At least close to the minimum delay + #expect(duration < 5.0) // But not too long + } + + @Test("No delay for file URLs") + internal func testNoDelayForFileURLs() async throws { + let fetcher = VirtualBuddyFetcher(apiKey: "test-key") + + let fileImage1 = RestoreImageRecord( + version: "14.0", + buildNumber: "23A344", + releaseDate: Date(), + downloadURL: URL(string: "file:///path1.ipsw")!, + fileSize: 1_000, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, + isPrerelease: false, + source: "local", + notes: nil, + sourceUpdatedAt: nil + ) + + let fileImage2 = RestoreImageRecord( + version: "14.1", + buildNumber: "23B5056e", + releaseDate: Date(), + downloadURL: URL(string: "file:///path2.ipsw")!, + fileSize: 1_000, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, + isPrerelease: false, + source: "local", + notes: nil, + sourceUpdatedAt: nil + ) + + let startTime = Date() + _ = try await fetcher.fetch(existingImages: [fileImage1, fileImage2]) + let duration = Date().timeIntervalSince(startTime) + + // Should complete immediately (file URLs skipped, no API calls) + #expect(duration < 0.5) + } + } + + // MARK: - API Response Parsing Tests + + @Suite("API Response Parsing") + internal struct APIResponseParsingTests { + @Test("Parse signed response correctly") + internal func testParseSignedResponse() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddySignedResponse.data(using: .utf8)! + return (response, data) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let testImage = RestoreImageRecord( + version: "15.0", + buildNumber: "24A5327a", + releaseDate: Date(), + downloadURL: URL(string: "https://example.com/test.ipsw")!, + fileSize: 1_000, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, + isPrerelease: true, + source: "test", + notes: nil, + sourceUpdatedAt: nil + ) + + let result = try await fetcher.fetch(existingImages: [testImage]) + + #expect(result.count == 1) + let enriched = try #require(result.first) + #expect(enriched.isSigned == true) + #expect(enriched.notes == "SUCCESS") + } + + @Test("Parse unsigned response correctly") + internal func testParseUnsignedResponse() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + MockURLProtocol.requestHandler = { _ in + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddyUnsignedResponse.data(using: .utf8)! + return (response, data) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "test-key", urlSession: mockSession) + + let testImage = RestoreImageRecord( + version: "15.1", + buildNumber: "24B5024e", + releaseDate: Date(), + downloadURL: URL(string: "https://example.com/test.ipsw")!, + fileSize: 1_000, + sha256Hash: "", + sha1Hash: "", + isSigned: nil, + isPrerelease: true, + source: "test", + notes: nil, + sourceUpdatedAt: nil + ) + + let result = try await fetcher.fetch(existingImages: [testImage]) + + #expect(result.count == 1) + let enriched = try #require(result.first) + #expect(enriched.isSigned == false) + #expect(enriched.notes?.contains("isn't eligible") == true) + } + + @Test("URL construction includes API key and IPSW parameter") + internal func testURLConstruction() async throws { + // Configure mock URLSession + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let mockSession = URLSession(configuration: config) + + var capturedRequest: URLRequest? + MockURLProtocol.requestHandler = { request in + capturedRequest = request + let url = URL(string: "https://tss.virtualbuddy.app")! + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)! + return (response, data) + } + + let fetcher = VirtualBuddyFetcher(apiKey: "my-api-key-123", urlSession: mockSession) + + _ = try await fetcher.fetch(existingImages: [TestFixtures.sonoma1421]) + + // Verify URL was constructed correctly + let request = try #require(capturedRequest) + let url = try #require(request.url) + + #expect(url.host == "tss.virtualbuddy.app") + #expect(url.path == "/v1/status") + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = try #require(components?.queryItems) + + #expect(queryItems.contains(where: { $0.name == "apiKey" && $0.value == "my-api-key-123" })) + #expect(queryItems.contains(where: { $0.name == "ipsw" })) + } + } +} // VirtualBuddyFetcherAllTests diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionDeduplicationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionDeduplicationTests.swift new file mode 100644 index 00000000..4fb2462c --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionDeduplicationTests.swift @@ -0,0 +1,84 @@ +// +// XcodeVersionDeduplicationTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +// MARK: - Suite 4: XcodeVersion Deduplication Tests + +@Suite("XcodeVersion Deduplication") +internal struct XcodeVersionDeduplicationTests { + internal let pipeline = DataSourcePipeline() + + @Test("Empty array returns empty") + internal func testDeduplicateEmpty() { + let result = pipeline.deduplicateXcodeVersions([]) + #expect(result.isEmpty) + } + + @Test("Single record returns unchanged") + internal func testDeduplicateSingle() { + let input = [TestFixtures.xcode151] + let result = pipeline.deduplicateXcodeVersions(input) + + #expect(result.count == 1) + #expect(result[0].buildNumber == "15C65") + } + + @Test("Duplicate builds keep first occurrence") + internal func testDuplicateBuildsKeepFirst() { + let input = [ + TestFixtures.xcode151, + TestFixtures.xcode151Duplicate, + ] + let result = pipeline.deduplicateXcodeVersions(input) + + #expect(result.count == 1) + // Should keep first occurrence + #expect(result[0].buildNumber == "15C65") + #expect(result[0].notes == "Release notes: https://developer.apple.com/xcode/release-notes/") + } + + @Test("Results sorted by release date descending") + internal func testSortingByReleaseDateDescending() { + let input = [ + TestFixtures.xcode151, // Dec 2023 + TestFixtures.xcode160, // Sep 2024 + ] + let result = pipeline.deduplicateXcodeVersions(input) + + #expect(result.count == 2) + // Verify descending order (newer first) + #expect(result[0].buildNumber == "16A242d") // xcode160 + #expect(result[1].buildNumber == "15C65") // xcode151 + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionReferenceResolutionTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionReferenceResolutionTests.swift new file mode 100644 index 00000000..4619641b --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionReferenceResolutionTests.swift @@ -0,0 +1,155 @@ +// +// XcodeVersionReferenceResolutionTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +// MARK: - Suite 3: XcodeVersion Reference Resolution Tests + +@Suite("XcodeVersion Reference Resolution") +internal struct XcodeVersionReferenceResolutionTests { + internal let pipeline = DataSourcePipeline() + + @Test("Resolve exact version match 14.2") + internal func testResolveExactMatch() { + let xcode = TestFixtures.xcodeWithRequires142 + let restoreImages = [TestFixtures.restoreImage142] + + let resolved = pipeline.resolveXcodeVersionReferences([xcode], restoreImages: restoreImages) + + #expect(resolved.count == 1) + #expect(resolved[0].minimumMacOS == "RestoreImage-23C64") + } + + @Test("Resolve 3-component version 14.2.1") + internal func testResolveThreeComponentVersion() { + let xcode = TestFixtures.xcodeWithRequires1421 + let restoreImages = [TestFixtures.sonoma1421] + + let resolved = pipeline.resolveXcodeVersionReferences([xcode], restoreImages: restoreImages) + + #expect(resolved.count == 1) + #expect(resolved[0].minimumMacOS == "RestoreImage-23C71") + } + + @Test("Resolve 2-component to 3-component match") + internal func testResolveTwoToThreeComponent() { + let xcode = TestFixtures.xcodeWithRequires142 + let restoreImages = [TestFixtures.sonoma1421] // version="14.2.1" + + let resolved = pipeline.resolveXcodeVersionReferences([xcode], restoreImages: restoreImages) + + // Should match via short version "14.2" to "14.2.1" + #expect(resolved.count == 1) + #expect(resolved[0].minimumMacOS == "RestoreImage-23C71") + } + + @Test("No match leaves minimumMacOS nil") + internal func testNoMatchLeavesNil() { + let xcode = TestFixtures.xcodeWithRequires142 + let restoreImages = [TestFixtures.sequoia151] // Different version + + let resolved = pipeline.resolveXcodeVersionReferences([xcode], restoreImages: restoreImages) + + #expect(resolved.count == 1) + #expect(resolved[0].minimumMacOS == nil) + } + + @Test("No REQUIRES field leaves minimumMacOS nil") + internal func testNoRequiresLeavesNil() { + let xcode = TestFixtures.xcodeNoRequires + let restoreImages = [TestFixtures.sonoma1421] + + let resolved = pipeline.resolveXcodeVersionReferences([xcode], restoreImages: restoreImages) + + #expect(resolved.count == 1) + #expect(resolved[0].minimumMacOS == nil) + } + + @Test("Invalid REQUIRES format leaves minimumMacOS nil") + internal func testInvalidRequiresLeavesNil() { + let xcode = TestFixtures.xcodeInvalidRequires + let restoreImages = [TestFixtures.sonoma1421] + + let resolved = pipeline.resolveXcodeVersionReferences([xcode], restoreImages: restoreImages) + + #expect(resolved.count == 1) + #expect(resolved[0].minimumMacOS == nil) + } + + @Test("NOTES_URL preserved after resolution") + internal func testNotesURLPreserved() { + let xcode = TestFixtures.xcodeWithRequires142 + let restoreImages = [TestFixtures.restoreImage142] + + let resolved = pipeline.resolveXcodeVersionReferences([xcode], restoreImages: restoreImages) + + #expect(resolved.count == 1) + #expect(resolved[0].notes == "https://developer.apple.com/notes") + } + + @Test("Empty restoreImages array leaves all nil") + internal func testEmptyRestoreImagesArray() { + let xcode = TestFixtures.xcodeWithRequires142 + let restoreImages: [RestoreImageRecord] = [] + + let resolved = pipeline.resolveXcodeVersionReferences([xcode], restoreImages: restoreImages) + + #expect(resolved.count == 1) + #expect(resolved[0].minimumMacOS == nil) + } + + @Test("Multiple Xcodes resolved correctly") + internal func testMultipleXcodeResolution() { + let xcodes = [ + TestFixtures.xcodeWithRequires142, + TestFixtures.xcodeWithRequires1421, + TestFixtures.xcodeNoRequires, + ] + let restoreImages = [ + TestFixtures.restoreImage142, + TestFixtures.sonoma1421, + ] + + let resolved = pipeline.resolveXcodeVersionReferences(xcodes, restoreImages: restoreImages) + + #expect(resolved.count == 3) + // When both "14.2" and "14.2.1" exist, the short version from "14.2.1" + // overwrites "14.2" in the lookup table (last processed wins) + // First should resolve to 14.2.1 (due to lookup table collision) + #expect(resolved[0].minimumMacOS == "RestoreImage-23C71") + // Second should resolve to 14.2.1 (exact match) + #expect(resolved[1].minimumMacOS == "RestoreImage-23C71") + // Third has no REQUIRES, should remain nil + #expect(resolved[2].minimumMacOS == nil) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift new file mode 100644 index 00000000..9aeadb43 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift @@ -0,0 +1,92 @@ +// +// AuthenticationErrorHandlingTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +// MARK: - Authentication Error Handling Tests + +@Suite("Authentication Error Handling Tests") +internal struct AuthenticationErrorHandlingTests { + @Test("CloudKit authentication failure") + internal func testCloudKitAuthFailure() async { + let service = MockCloudKitService() + await service.setShouldFailQuery(true) + await service.setQueryError(MockCloudKitError.authenticationFailed) + + do { + _ = try await service.queryRecords(recordType: "RestoreImage") + Issue.record("Expected authentication error to be thrown") + } catch let error as MockCloudKitError { + if case .authenticationFailed = error { + // Success + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("CloudKit access denied") + internal func testCloudKitAccessDenied() async { + let service = MockCloudKitService() + await service.setShouldFailQuery(true) + await service.setQueryError(MockCloudKitError.accessDenied) + + do { + _ = try await service.queryRecords(recordType: "RestoreImage") + Issue.record("Expected access denied error to be thrown") + } catch let error as MockCloudKitError { + if case .accessDenied = error { + // Success + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Data source authentication failure") + internal func testDataSourceAuthFailure() async { + let fetcher = MockXcodeReleasesFetcher( + errorToThrow: MockFetcherError.authenticationFailed + ) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected authentication error to be thrown") + } catch is MockFetcherError { + // Success + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift new file mode 100644 index 00000000..92c88015 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift @@ -0,0 +1,140 @@ +// +// CloudKitErrorHandlingTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import BushelCloudKit + +// MARK: - CloudKit-Specific Error Handling Tests + +@Suite("CloudKit Error Handling Tests") +internal struct CloudKitErrorHandlingTests { + @Test("Quota exceeded error") + internal func testQuotaExceeded() async { + let service = MockCloudKitService() + await service.setShouldFailModify(true) + await service.setModifyError(MockCloudKitError.quotaExceeded) + + let operation = RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: "test", + fields: TestFixtures.sonoma1421.toCloudKitFields() + ) + + do { + try await service.executeBatchOperations([operation], recordType: "RestoreImage") + Issue.record("Expected quota exceeded error to be thrown") + } catch let error as MockCloudKitError { + if case .quotaExceeded = error { + // Success + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Reference validation error") + internal func testValidatingReferenceError() async { + let service = MockCloudKitService() + await service.setShouldFailModify(true) + await service.setModifyError(MockCloudKitError.validatingReferenceError) + + let operation = RecordOperation( + operationType: .create, + recordType: "XcodeVersion", + recordName: "XcodeVersion-15C65", + fields: TestFixtures.xcode151.toCloudKitFields() + ) + + do { + try await service.executeBatchOperations([operation], recordType: "XcodeVersion") + Issue.record("Expected reference validation error to be thrown") + } catch let error as MockCloudKitError { + if case .validatingReferenceError = error { + // Success + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Conflict error on duplicate create") + internal func testConflictError() async { + let service = MockCloudKitService() + await service.setShouldFailModify(true) + await service.setModifyError(MockCloudKitError.conflict) + + let operation = RecordOperation( + operationType: .create, + recordType: "RestoreImage", + recordName: "test", + fields: TestFixtures.sonoma1421.toCloudKitFields() + ) + + do { + try await service.executeBatchOperations([operation], recordType: "RestoreImage") + Issue.record("Expected conflict error to be thrown") + } catch let error as MockCloudKitError { + if case .conflict = error { + // Success + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Unknown CloudKit error") + internal func testUnknownError() async { + let service = MockCloudKitService() + await service.setShouldFailQuery(true) + await service.setQueryError(MockCloudKitError.unknownError("Something went wrong")) + + do { + _ = try await service.queryRecords(recordType: "RestoreImage") + Issue.record("Expected unknown error to be thrown") + } catch let error as MockCloudKitError { + if case .unknownError(let message) = error { + #expect(message == "Something went wrong") + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/GracefulDegradationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/GracefulDegradationTests.swift new file mode 100644 index 00000000..aba5179e --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/GracefulDegradationTests.swift @@ -0,0 +1,77 @@ +// +// GracefulDegradationTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +// MARK: - Graceful Degradation Tests + +@Suite("Graceful Degradation Tests") +internal struct GracefulDegradationTests { + @Test("Single fetcher failure doesn't block others") + internal func testPartialFetcherFailure() async { + // Simulate one fetcher failing while others succeed + let ipswFetcher = MockIPSWFetcher( + recordsToReturn: [TestFixtures.sonoma1421] + ) + let appleDBFetcher = MockAppleDBFetcher( + errorToThrow: MockFetcherError.networkError("Network unavailable") + ) + + // IPSW should succeed + do { + let ipswResults = try await ipswFetcher.fetch() + #expect(ipswResults.count == 1) + } catch { + Issue.record("IPSW fetcher should have succeeded") + } + + // AppleDB should fail gracefully + do { + _ = try await appleDBFetcher.fetch() + Issue.record("AppleDB fetcher should have failed") + } catch { + // Expected to fail + } + } + + @Test("Empty results handled gracefully") + internal func testEmptyResults() async throws { + let fetcher = MockIPSWFetcher(recordsToReturn: []) + let results = try await fetcher.fetch() + #expect(results.isEmpty) + } + + @Test("Nil results from optional fetcher") + internal func testNilResults() async throws { + let fetcher = MockMESUFetcher(recordToReturn: nil) + let result = try await fetcher.fetch() + #expect(result == nil) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/NetworkErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/NetworkErrorHandlingTests.swift new file mode 100644 index 00000000..3163b909 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/NetworkErrorHandlingTests.swift @@ -0,0 +1,152 @@ +// +// NetworkErrorHandlingTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +// MARK: - Network Error Handling Tests + +@Suite("Network Error Handling Tests") +internal struct NetworkErrorHandlingTests { + @Test("Handle network timeout gracefully") + internal func testNetworkTimeout() async { + let fetcher = MockIPSWFetcher(errorToThrow: MockFetcherError.timeout) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected timeout error to be thrown") + } catch let error as MockFetcherError { + if case .timeout = error { + // Success - timeout handled correctly + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Handle connection failure") + internal func testConnectionFailure() async { + let fetcher = MockAppleDBFetcher( + errorToThrow: MockFetcherError.networkError("Connection refused") + ) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected network error to be thrown") + } catch let error as MockFetcherError { + if case .networkError(let message) = error { + #expect(message.contains("refused")) + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Handle DNS resolution failure") + internal func testDNSFailure() async { + let fetcher = MockXcodeReleasesFetcher( + errorToThrow: MockFetcherError.networkError("DNS resolution failed") + ) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected DNS error to be thrown") + } catch let error as MockFetcherError { + if case .networkError(let message) = error { + #expect(message.contains("DNS")) + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Handle server errors (5xx)") + internal func testServerErrors() async { + for errorCode in [500, 502, 503, 504] { + let fetcher = MockAppleDBFetcher( + errorToThrow: MockFetcherError.serverError(code: errorCode) + ) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected server error \(errorCode) to be thrown") + } catch let error as MockFetcherError { + if case .serverError(let code) = error { + #expect(code == errorCode) + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + } + + @Test("Handle client errors (4xx)") + internal func testClientErrors() async { + for errorCode in [400, 401, 403, 404, 429] { + let fetcher = MockIPSWFetcher( + errorToThrow: MockFetcherError.serverError(code: errorCode) + ) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected client error \(errorCode) to be thrown") + } catch let error as MockFetcherError { + if case .serverError(let code) = error { + #expect(code == errorCode) + } else { + Issue.record("Wrong error type: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + } + + @Test("Handle invalid response data") + internal func testInvalidResponse() async { + let fetcher = MockMESUFetcher(errorToThrow: MockFetcherError.invalidResponse) + + do { + _ = try await fetcher.fetch() + Issue.record("Expected invalid response error to be thrown") + } catch is MockFetcherError { + // Success + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift new file mode 100644 index 00000000..2040b78d --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift @@ -0,0 +1,207 @@ +// +// FieldValueURLTests.swift +// BushelCloudTests +// +// Created by Claude Code +// + +import Foundation +import MistKit +import Testing + +@testable import BushelCloudKit + +internal struct FieldValueURLTests { + // MARK: - URL → FieldValue Conversion Tests + + @Test("Create FieldValue from URL") + internal func testCreateFieldValueFromURL() throws { + let url = URL(string: "https://example.com/file.dmg")! + let fieldValue = FieldValue(url: url) + + if case .string(let value) = fieldValue { + #expect(value == "https://example.com/file.dmg") + } else { + Issue.record("Expected .string FieldValue") + } + } + + @Test("Create FieldValue from URL with path") + internal func testCreateFieldValueFromURLWithPath() throws { + let url = URL(string: "https://example.com/path/to/file.ipsw")! + let fieldValue = FieldValue(url: url) + + if case .string(let value) = fieldValue { + #expect(value == "https://example.com/path/to/file.ipsw") + } else { + Issue.record("Expected .string FieldValue") + } + } + + @Test("Create FieldValue from URL with query parameters") + internal func testCreateFieldValueFromURLWithQueryParams() throws { + let url = URL(string: "https://example.com/file.dmg?version=1.0&platform=mac")! + let fieldValue = FieldValue(url: url) + + if case .string(let value) = fieldValue { + #expect(value == "https://example.com/file.dmg?version=1.0&platform=mac") + } else { + Issue.record("Expected .string FieldValue") + } + } + + @Test("Create FieldValue from file URL") + internal func testCreateFieldValueFromFileURL() throws { + let url = URL(fileURLWithPath: "/Users/test/file.dmg") + let fieldValue = FieldValue(url: url) + + if case .string(let value) = fieldValue { + #expect(value == "file:///Users/test/file.dmg") + } else { + Issue.record("Expected .string FieldValue") + } + } + + // MARK: - FieldValue → URL Extraction Tests + + @Test("Extract URL from string FieldValue") + internal func testExtractURLFromStringFieldValue() throws { + let fieldValue: FieldValue = .string("https://example.com/file.dmg") + let url = fieldValue.urlValue + + #expect(url != nil) + #expect(url?.absoluteString == "https://example.com/file.dmg") + } + + @Test("Extract URL from string FieldValue with path") + internal func testExtractURLFromStringFieldValueWithPath() throws { + let fieldValue: FieldValue = .string("https://downloads.apple.com/restore/macOS/23C71.ipsw") + let url = fieldValue.urlValue + + #expect(url != nil) + #expect(url?.absoluteString == "https://downloads.apple.com/restore/macOS/23C71.ipsw") + } + + @Test("Extract nil from invalid URL string") + internal func testExtractNilFromInvalidURLString() throws { + // URL(string:) is lenient - even "not a valid url" parses successfully + // as a relative URL with no scheme. Test actual parsing behavior: + let fieldValue: FieldValue = .string("not a valid url") + let url = fieldValue.urlValue + + // This actually succeeds - URL is lenient and creates a relative URL + #expect(url != nil) + #expect(url?.scheme == nil) // No scheme for this "URL" + + // Test a URL with invalid characters + let malformedFieldValue: FieldValue = .string("ht!tp://invalid") + let malformedURL = malformedFieldValue.urlValue + + // URLs with certain invalid characters DO fail to parse + // This demonstrates that URL(string:) has some limits + #expect(malformedURL == nil) + } + + @Test("Extract nil from empty string") + internal func testExtractNilFromEmptyString() throws { + let fieldValue: FieldValue = .string("") + let url = fieldValue.urlValue + + // Note: URL(string: "") returns nil (empty string is not a valid URL) + #expect(url == nil) + } + + @Test("Extract nil from non-string FieldValue") + internal func testExtractNilFromNonStringFieldValue() throws { + let intFieldValue: FieldValue = .int64(42) + #expect(intFieldValue.urlValue == nil) + + let doubleFieldValue: FieldValue = .double(3.14) + #expect(doubleFieldValue.urlValue == nil) + + let dateFieldValue: FieldValue = .date(Date()) + #expect(dateFieldValue.urlValue == nil) + } + + // MARK: - Round-Trip Tests + + @Test("Round-trip URL through FieldValue") + internal func testRoundTripURLThroughFieldValue() throws { + let originalURL = URL(string: "https://example.com/file.dmg")! + let fieldValue = FieldValue(url: originalURL) + let extractedURL = fieldValue.urlValue + + #expect(extractedURL != nil) + #expect(extractedURL?.absoluteString == originalURL.absoluteString) + } + + @Test("Round-trip complex URL through FieldValue") + internal func testRoundTripComplexURLThroughFieldValue() throws { + let originalURL = URL( + string: + "https://updates.cdn-apple.com/2024/restore/macOS/" + + "052-49876-20241103-B6C6AA6A-D39E-4F6C-B43C-15C3B8A4CB1A/UniversalMac_15.1.1_24B91_Restore.ipsw" + )! + let fieldValue = FieldValue(url: originalURL) + let extractedURL = fieldValue.urlValue + + #expect(extractedURL != nil) + #expect(extractedURL?.absoluteString == originalURL.absoluteString) + } + + @Test("Round-trip file URL through FieldValue") + internal func testRoundTripFileURLThroughFieldValue() throws { + let originalURL = URL(fileURLWithPath: "/System/Library/Frameworks/Virtualization.framework") + let fieldValue = FieldValue(url: originalURL) + let extractedURL = fieldValue.urlValue + + #expect(extractedURL != nil) + #expect(extractedURL?.absoluteString == originalURL.absoluteString) + #expect(extractedURL?.isFileURL == true) + } + + // MARK: - Type Safety Tests + + @Test("FieldValue from URL is string type") + internal func testFieldValueFromURLIsStringType() throws { + let url = URL(string: "https://example.com")! + let fieldValue = FieldValue(url: url) + + switch fieldValue { + case .string: + #expect(true) + default: + Issue.record("Expected .string FieldValue, got \(fieldValue)") + } + } + + @Test("URL extraction preserves scheme") + internal func testURLExtractionPreservesScheme() throws { + let httpsFieldValue = FieldValue(url: URL(string: "https://example.com")!) + let httpFieldValue = FieldValue(url: URL(string: "http://example.com")!) + let fileFieldValue = FieldValue(url: URL(fileURLWithPath: "/tmp/file")) + + #expect(httpsFieldValue.urlValue?.scheme == "https") + #expect(httpFieldValue.urlValue?.scheme == "http") + #expect(fileFieldValue.urlValue?.scheme == "file") + } + + // MARK: - CloudKit Integration Tests + + @Test("FieldValue URL matches CloudKit STRING field format") + internal func testFieldValueURLMatchesCloudKitStringFormat() throws { + // CloudKit stores URLs as STRING fields + // This test verifies the format is compatible + let url = URL(string: "https://downloads.apple.com/restore/macOS/23C71.ipsw")! + let fieldValue = FieldValue(url: url) + + // When sent to CloudKit, this becomes a STRING field with the absolute URL + if case .string(let stringValue) = fieldValue { + // Verify it's a valid absolute URL string + #expect(stringValue.hasPrefix("https://")) + #expect(URL(string: stringValue) != nil) + } else { + Issue.record("FieldValue should be .string type for CloudKit compatibility") + } + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockAppleDBFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockAppleDBFetcher.swift new file mode 100644 index 00000000..dbca9a82 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockAppleDBFetcher.swift @@ -0,0 +1,52 @@ +// +// MockAppleDBFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +@testable import BushelFoundation + +/// Mock fetcher for AppleDB data source +internal struct MockAppleDBFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [RestoreImageRecord] + + internal let recordsToReturn: [RestoreImageRecord] + internal let errorToThrow: (any Error)? + + internal init(recordsToReturn: [RestoreImageRecord] = [], errorToThrow: (any Error)? = nil) { + self.recordsToReturn = recordsToReturn + self.errorToThrow = errorToThrow + } + + internal func fetch() async throws -> [RestoreImageRecord] { + if let error = errorToThrow { + throw error + } + return recordsToReturn + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift new file mode 100644 index 00000000..b633748c --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift @@ -0,0 +1,177 @@ +// +// MockCloudKitService.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +// MARK: - Mock CloudKit Errors + +internal enum MockCloudKitError: Error, Sendable { + case authenticationFailed + case accessDenied + case quotaExceeded + case validatingReferenceError + case conflict + case networkError + case unknownError(String) +} + +// MARK: - Mock CloudKit Service + +/// Mock CloudKit service for testing without real CloudKit calls +internal actor MockCloudKitService: RecordManaging { + // MARK: - Storage + + private var storedRecords: [String: [RecordInfo]] = [:] + private var operationHistory: [[RecordOperation]] = [] + + // MARK: - Configuration + + internal var shouldFailQuery: Bool = false + internal var shouldFailModify: Bool = false + internal var queryError: (any Error)? + internal var modifyError: (any Error)? + + // MARK: - Inspection Methods + + internal func getStoredRecords(ofType recordType: String) -> [RecordInfo] { + storedRecords[recordType] ?? [] + } + + internal func getOperationHistory() -> [[RecordOperation]] { + operationHistory + } + + internal func clearStorage() { + storedRecords.removeAll() + operationHistory.removeAll() + } + + // MARK: - RecordManaging Protocol + + internal func queryRecords(recordType: String) async throws -> [RecordInfo] { + if shouldFailQuery { + throw queryError ?? MockCloudKitError.networkError + } + return storedRecords[recordType] ?? [] + } + + internal func executeBatchOperations( + _ operations: [RecordOperation], + recordType: String + ) async throws { + operationHistory.append(operations) + + if shouldFailModify { + throw modifyError ?? MockCloudKitError.networkError + } + + // Process operations + for operation in operations { + switch operation.operationType { + case .create, .forceReplace: + handleCreateOrReplace(operation, recordType: recordType) + case .delete, .forceDelete: + handleDelete(operation, recordType: recordType) + case .update: + handleUpdate(operation, recordType: recordType) + case .forceUpdate: + handleForceUpdate(operation, recordType: recordType) + case .replace: + handleReplace(operation, recordType: recordType) + } + } + } + + // MARK: - Operation Handlers + + private func handleCreateOrReplace(_ operation: RecordOperation, recordType: String) { + let recordInfo = createRecordInfo(from: operation) + var records = storedRecords[recordType] ?? [] + + // For forceReplace, remove existing record with same name + if operation.operationType == .forceReplace { + records.removeAll { $0.recordName == operation.recordName } + } + + records.append(recordInfo) + storedRecords[recordType] = records + } + + private func handleDelete(_ operation: RecordOperation, recordType: String) { + guard let recordName = operation.recordName else { + return + } + storedRecords[recordType]?.removeAll { $0.recordName == recordName } + } + + private func handleUpdate(_ operation: RecordOperation, recordType: String) { + guard let recordName = operation.recordName, + let index = storedRecords[recordType]?.firstIndex(where: { $0.recordName == recordName }) + else { return } + + let updatedRecordInfo = createRecordInfo(from: operation) + storedRecords[recordType]?[index] = updatedRecordInfo + } + + private func handleForceUpdate(_ operation: RecordOperation, recordType: String) { + guard let recordName = operation.recordName else { + return + } + let updatedRecordInfo = createRecordInfo(from: operation) + + if let index = storedRecords[recordType]?.firstIndex(where: { $0.recordName == recordName }) { + storedRecords[recordType]?[index] = updatedRecordInfo + } else { + var records = storedRecords[recordType] ?? [] + records.append(updatedRecordInfo) + storedRecords[recordType] = records + } + } + + private func handleReplace(_ operation: RecordOperation, recordType: String) { + guard let recordName = operation.recordName, + let index = storedRecords[recordType]?.firstIndex(where: { $0.recordName == recordName }) + else { return } + + let updatedRecordInfo = createRecordInfo(from: operation) + storedRecords[recordType]?[index] = updatedRecordInfo + } + + // MARK: - Helper Methods + + private func createRecordInfo(from operation: RecordOperation) -> RecordInfo { + RecordInfo( + recordName: operation.recordName ?? UUID().uuidString, + recordType: operation.recordType, + recordChangeTag: UUID().uuidString, + fields: operation.fields + ) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockFetcherError.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockFetcherError.swift new file mode 100644 index 00000000..0f56c232 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockFetcherError.swift @@ -0,0 +1,38 @@ +// +// MockFetcherError.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +internal enum MockFetcherError: Error, Sendable { + case networkError(String) + case authenticationFailed + case invalidResponse + case timeout + case serverError(code: Int) +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockIPSWFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockIPSWFetcher.swift new file mode 100644 index 00000000..73cbb943 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockIPSWFetcher.swift @@ -0,0 +1,52 @@ +// +// MockIPSWFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +@testable import BushelFoundation + +/// Mock fetcher for IPSW data source +internal struct MockIPSWFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [RestoreImageRecord] + + internal let recordsToReturn: [RestoreImageRecord] + internal let errorToThrow: (any Error)? + + internal init(recordsToReturn: [RestoreImageRecord] = [], errorToThrow: (any Error)? = nil) { + self.recordsToReturn = recordsToReturn + self.errorToThrow = errorToThrow + } + + internal func fetch() async throws -> [RestoreImageRecord] { + if let error = errorToThrow { + throw error + } + return recordsToReturn + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockMESUFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockMESUFetcher.swift new file mode 100644 index 00000000..93611dbb --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockMESUFetcher.swift @@ -0,0 +1,52 @@ +// +// MockMESUFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +@testable import BushelFoundation + +/// Mock fetcher for MESU data source +internal struct MockMESUFetcher: DataSourceFetcher, Sendable { + internal typealias Record = RestoreImageRecord? + + internal let recordToReturn: RestoreImageRecord? + internal let errorToThrow: (any Error)? + + internal init(recordToReturn: RestoreImageRecord? = nil, errorToThrow: (any Error)? = nil) { + self.recordToReturn = recordToReturn + self.errorToThrow = errorToThrow + } + + internal func fetch() async throws -> RestoreImageRecord? { + if let error = errorToThrow { + throw error + } + return recordToReturn + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockSwiftVersionFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockSwiftVersionFetcher.swift new file mode 100644 index 00000000..2f28cde1 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockSwiftVersionFetcher.swift @@ -0,0 +1,52 @@ +// +// MockSwiftVersionFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +@testable import BushelFoundation + +/// Mock fetcher for Swift version data source +internal struct MockSwiftVersionFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [SwiftVersionRecord] + + internal let recordsToReturn: [SwiftVersionRecord] + internal let errorToThrow: (any Error)? + + internal init(recordsToReturn: [SwiftVersionRecord] = [], errorToThrow: (any Error)? = nil) { + self.recordsToReturn = recordsToReturn + self.errorToThrow = errorToThrow + } + + internal func fetch() async throws -> [SwiftVersionRecord] { + if let error = errorToThrow { + throw error + } + return recordsToReturn + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockURLProtocol.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockURLProtocol.swift new file mode 100644 index 00000000..368524fe --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockURLProtocol.swift @@ -0,0 +1,72 @@ +// +// MockURLProtocol.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Mock URLProtocol for intercepting and simulating HTTP requests in tests +internal final class MockURLProtocol: URLProtocol, @unchecked Sendable { + /// Request handler that returns a response and optional data for a given request + nonisolated(unsafe) internal static var requestHandler: + ((URLRequest) throws -> (HTTPURLResponse, Data?))? + + override internal class func canInit(with request: URLRequest) -> Bool { + // Handle all requests + true + } + + override internal class func canonicalRequest(for request: URLRequest) -> URLRequest { + // Return the request as-is + request + } + + override internal func startLoading() { + guard let handler = Self.requestHandler else { + fatalError("MockURLProtocol: Request handler not configured") + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let data = data { + client?.urlProtocol(self, didLoad: data) + } + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override internal func stopLoading() { + // Nothing to clean up + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockXcodeReleasesFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockXcodeReleasesFetcher.swift new file mode 100644 index 00000000..1b018b2b --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockXcodeReleasesFetcher.swift @@ -0,0 +1,52 @@ +// +// MockXcodeReleasesFetcher.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +@testable import BushelFoundation + +/// Mock fetcher for Xcode Releases data source +internal struct MockXcodeReleasesFetcher: DataSourceFetcher, Sendable { + internal typealias Record = [XcodeVersionRecord] + + internal let recordsToReturn: [XcodeVersionRecord] + internal let errorToThrow: (any Error)? + + internal init(recordsToReturn: [XcodeVersionRecord] = [], errorToThrow: (any Error)? = nil) { + self.recordsToReturn = recordsToReturn + self.errorToThrow = errorToThrow + } + + internal func fetch() async throws -> [XcodeVersionRecord] { + if let error = errorToThrow { + throw error + } + return recordsToReturn + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/DataSourceMetadataTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/DataSourceMetadataTests.swift new file mode 100644 index 00000000..034c7f93 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/DataSourceMetadataTests.swift @@ -0,0 +1,92 @@ +// +// DataSourceMetadataTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// + +import BushelFoundation +import MistKit +import Testing + +@testable import BushelCloudKit + +@Suite("DataSourceMetadata CloudKit Mapping") +internal struct DataSourceMetadataTests { + @Test("Convert successful fetch to CloudKit fields") + internal func testToCloudKitFieldsSuccess() { + let record = TestFixtures.metadataIPSWSuccess + let fields = record.toCloudKitFields() + + fields["sourceName"]?.assertStringEquals("ipsw.me") + fields["recordTypeName"]?.assertStringEquals("RestoreImage") + fields["lastFetchedAt"]?.assertIsDate() + fields["sourceUpdatedAt"]?.assertIsDate() + fields["recordCount"]?.assertInt64Equals(42) + fields["fetchDurationSeconds"]?.assertDoubleEquals(3.5) + + #expect(fields["lastError"] == nil) + } + + @Test("Convert failed fetch to CloudKit fields") + internal func testToCloudKitFieldsWithError() { + let record = TestFixtures.metadataAppleDBError + let fields = record.toCloudKitFields() + + fields["sourceName"]?.assertStringEquals("appledb.dev") + fields["recordTypeName"]?.assertStringEquals("RestoreImage") + fields["recordCount"]?.assertInt64Equals(0) + fields["fetchDurationSeconds"]?.assertDoubleEquals(1.2) + fields["lastError"]?.assertStringEquals("HTTP 404: Not Found") + + #expect(fields["sourceUpdatedAt"] == nil) + } + + @Test("Roundtrip conversion preserves data") + internal func testRoundtripConversion() { + let original = TestFixtures.metadataIPSWSuccess + let fields = original.toCloudKitFields() + let recordInfo = MockRecordInfo.create( + recordType: "DataSourceMetadata", + recordName: original.recordName, + fields: fields + ) + + let reconstructed = DataSourceMetadata.from(recordInfo: recordInfo) + + #expect(reconstructed != nil) + #expect(reconstructed?.sourceName == original.sourceName) + #expect(reconstructed?.recordTypeName == original.recordTypeName) + #expect(reconstructed?.recordCount == original.recordCount) + #expect(reconstructed?.fetchDurationSeconds == original.fetchDurationSeconds) + #expect(reconstructed?.lastError == original.lastError) + } + + @Test("From RecordInfo with missing required fields returns nil") + internal func testFromRecordInfoMissingFields() { + let recordInfo = MockRecordInfo.create( + recordType: "DataSourceMetadata", + recordName: "test", + fields: [ + "sourceName": .string("ipsw.me") + // Missing recordTypeName and lastFetchedAt + ] + ) + + #expect(DataSourceMetadata.from(recordInfo: recordInfo) == nil) + } + + @Test("RecordName generation format") + internal func testRecordNameFormat() { + #expect(TestFixtures.metadataIPSWSuccess.recordName == "metadata-ipsw.me-RestoreImage") + #expect( + TestFixtures.metadataXcodeReleases.recordName == "metadata-xcodereleases.com-XcodeVersion" + ) + } + + @Test("CloudKit record type is correct") + internal func testCloudKitRecordType() { + #expect(DataSourceMetadata.cloudKitRecordType == "DataSourceMetadata") + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/RestoreImageRecordTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/RestoreImageRecordTests.swift new file mode 100644 index 00000000..fcb14180 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/RestoreImageRecordTests.swift @@ -0,0 +1,197 @@ +// +// RestoreImageRecordTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +@Suite("RestoreImageRecord CloudKit Mapping") +internal struct RestoreImageRecordTests { + @Test("Convert to CloudKit fields with all data") + internal func testToCloudKitFieldsComplete() { + let record = TestFixtures.sonoma1421 + let fields = record.toCloudKitFields() + + // Required fields + fields["version"]?.assertStringEquals("14.2.1") + fields["buildNumber"]?.assertStringEquals("23C71") + fields["downloadURL"]?.assertStringEquals( + "https://updates.cdn-apple.com/2023/macos/23C71/RestoreImage.ipsw" + ) + fields["fileSize"]?.assertInt64Equals(13_500_000_000) + fields["sha256Hash"]?.assertStringEquals( + "abc123def456789abcdef0123456789abcdef0123456789abcdef0123456789ab" + ) + fields["sha1Hash"]?.assertStringEquals("def4567890123456789abcdef01234567890") + fields["isPrerelease"]?.assertBoolEquals(false) + fields["source"]?.assertStringEquals("ipsw.me") + + // Optional fields + fields["isSigned"]?.assertBoolEquals(true) + fields["notes"]?.assertStringEquals("Stable release for macOS Sonoma") + fields["releaseDate"]?.assertIsDate() + fields["sourceUpdatedAt"]?.assertIsDate() + } + + @Test("Convert beta record to CloudKit fields") + internal func testToCloudKitFieldsBeta() { + let record = TestFixtures.sequoia150Beta + let fields = record.toCloudKitFields() + + fields["version"]?.assertStringEquals("15.0 Beta 3") + fields["buildNumber"]?.assertStringEquals("24A5264n") + fields["isPrerelease"]?.assertBoolEquals(true) + fields["isSigned"]?.assertBoolEquals(false) + fields["source"]?.assertStringEquals("mrmacintosh.com") + } + + @Test("Convert minimal record without optional fields") + internal func testToCloudKitFieldsMinimal() { + let record = TestFixtures.minimalRestoreImage + let fields = record.toCloudKitFields() + + // Should have required fields + fields["version"]?.assertStringEquals("14.0") + fields["buildNumber"]?.assertStringEquals("23A344") + fields["isPrerelease"]?.assertBoolEquals(false) + + // Should NOT have optional fields + #expect(fields["isSigned"] == nil) + #expect(fields["notes"] == nil) + #expect(fields["sourceUpdatedAt"] == nil) + } + + @Test("Roundtrip conversion preserves data") + internal func testRoundtripConversion() { + let original = TestFixtures.sonoma1421 + let fields = original.toCloudKitFields() + let recordInfo = MockRecordInfo.create( + recordType: "RestoreImage", + recordName: original.recordName, + fields: fields + ) + + let reconstructed = RestoreImageRecord.from(recordInfo: recordInfo) + + #expect(reconstructed != nil) + #expect(reconstructed?.version == original.version) + #expect(reconstructed?.buildNumber == original.buildNumber) + #expect(reconstructed?.downloadURL == original.downloadURL) + #expect(reconstructed?.fileSize == original.fileSize) + #expect(reconstructed?.sha256Hash == original.sha256Hash) + #expect(reconstructed?.sha1Hash == original.sha1Hash) + #expect(reconstructed?.isSigned == original.isSigned) + #expect(reconstructed?.isPrerelease == original.isPrerelease) + #expect(reconstructed?.source == original.source) + #expect(reconstructed?.notes == original.notes) + } + + @Test("Roundtrip conversion with optional boolean nil") + internal func testRoundtripWithNilOptionalBoolean() { + let original = TestFixtures.minimalRestoreImage + let fields = original.toCloudKitFields() + let recordInfo = MockRecordInfo.create( + recordType: "RestoreImage", + recordName: original.recordName, + fields: fields + ) + + let reconstructed = RestoreImageRecord.from(recordInfo: recordInfo) + + #expect(reconstructed != nil) + #expect(reconstructed?.isSigned == nil) + } + + @Test("From RecordInfo with missing required fields returns nil") + internal func testFromRecordInfoMissingFields() { + let recordInfo = MockRecordInfo.create( + recordType: "RestoreImage", + recordName: "test", + fields: [ + "version": .string("14.2.1"), + "buildNumber": .string("23C71"), + // Missing other required fields + ] + ) + + let result = RestoreImageRecord.from(recordInfo: recordInfo) + #expect(result == nil) + } + + @Test("RecordName generation format") + internal func testRecordNameFormat() { + let record = TestFixtures.sonoma1421 + #expect(record.recordName == "RestoreImage-23C71") + + let betaRecord = TestFixtures.sequoia150Beta + #expect(betaRecord.recordName == "RestoreImage-24A5264n") + } + + @Test("CloudKit record type is correct") + internal func testCloudKitRecordType() { + #expect(RestoreImageRecord.cloudKitRecordType == "RestoreImage") + } + + @Test("Boolean field conversion", arguments: [true, false]) + internal func testBooleanConversion(value: Bool) { + let record = RestoreImageRecord( + version: "14.0", + buildNumber: "23A344", + releaseDate: Date(), + downloadURL: URL(string: "https://example.com/image.ipsw")!, + fileSize: 10_000_000_000, + sha256Hash: "hash256", + sha1Hash: "hash1", + isSigned: value, + isPrerelease: value, + source: "test" + ) + + let fields = record.toCloudKitFields() + fields["isSigned"]?.assertBoolEquals(value) + fields["isPrerelease"]?.assertBoolEquals(value) + } + + @Test("Format for display produces non-empty string") + internal func testFormatForDisplay() { + let fields = TestFixtures.sonoma1421.toCloudKitFields() + let recordInfo = MockRecordInfo.create( + recordType: "RestoreImage", + recordName: "RestoreImage-23C71", + fields: fields + ) + + let formatted = RestoreImageRecord.formatForDisplay(recordInfo) + #expect(!formatted.isEmpty) + #expect(formatted.contains("23C71")) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/SwiftVersionRecordTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/SwiftVersionRecordTests.swift new file mode 100644 index 00000000..03657a35 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/SwiftVersionRecordTests.swift @@ -0,0 +1,86 @@ +// +// SwiftVersionRecordTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// + +import MistKit +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +@Suite("SwiftVersionRecord CloudKit Mapping") +internal struct SwiftVersionRecordTests { + @Test("Convert to CloudKit fields with all data") + internal func testToCloudKitFieldsComplete() { + let record = TestFixtures.swift592 + let fields = record.toCloudKitFields() + + fields["version"]?.assertStringEquals("5.9.2") + fields["releaseDate"]?.assertIsDate() + fields["isPrerelease"]?.assertBoolEquals(false) + fields["downloadURL"]?.assertStringEquals( + "https://download.swift.org/swift-5.9.2-release/xcode/swift-5.9.2-RELEASE-osx.pkg" + ) + fields["notes"]?.assertStringEquals("Stable Swift release bundled with Xcode 15.1") + } + + @Test("Convert snapshot record to CloudKit fields") + internal func testToCloudKitFieldsSnapshot() { + let record = TestFixtures.swift60Snapshot + let fields = record.toCloudKitFields() + + fields["version"]?.assertStringEquals("6.0") + fields["isPrerelease"]?.assertBoolEquals(true) + + #expect(fields["downloadURL"] == nil) + #expect(fields["notes"] == nil) + } + + @Test("Roundtrip conversion preserves data") + internal func testRoundtripConversion() { + let original = TestFixtures.swift592 + let fields = original.toCloudKitFields() + let recordInfo = MockRecordInfo.create( + recordType: "SwiftVersion", + recordName: original.recordName, + fields: fields + ) + + let reconstructed = SwiftVersionRecord.from(recordInfo: recordInfo) + + #expect(reconstructed != nil) + #expect(reconstructed?.version == original.version) + #expect(reconstructed?.isPrerelease == original.isPrerelease) + #expect(reconstructed?.downloadURL == original.downloadURL) + #expect(reconstructed?.notes == original.notes) + } + + @Test("From RecordInfo with missing required fields returns nil") + internal func testFromRecordInfoMissingFields() { + let recordInfo = MockRecordInfo.create( + recordType: "SwiftVersion", + recordName: "test", + fields: [ + "version": .string("5.9.2") + // Missing releaseDate + ] + ) + + #expect(SwiftVersionRecord.from(recordInfo: recordInfo) == nil) + } + + @Test("RecordName generation format") + internal func testRecordNameFormat() { + #expect(TestFixtures.swift592.recordName == "SwiftVersion-5.9.2") + #expect(TestFixtures.swift60Snapshot.recordName == "SwiftVersion-6.0") + } + + @Test("CloudKit record type is correct") + internal func testCloudKitRecordType() { + #expect(SwiftVersionRecord.cloudKitRecordType == "SwiftVersion") + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/XcodeVersionRecordTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/XcodeVersionRecordTests.swift new file mode 100644 index 00000000..709754b5 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/XcodeVersionRecordTests.swift @@ -0,0 +1,117 @@ +// +// XcodeVersionRecordTests.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// + +import MistKit +import Testing + +@testable import BushelCloudKit +@testable import BushelFoundation + +@Suite("XcodeVersionRecord CloudKit Mapping") +internal struct XcodeVersionRecordTests { + @Test("Convert to CloudKit fields with all data") + internal func testToCloudKitFieldsComplete() { + let record = TestFixtures.xcode151 + let fields = record.toCloudKitFields() + + fields["version"]?.assertStringEquals("15.1") + fields["buildNumber"]?.assertStringEquals("15C65") + fields["isPrerelease"]?.assertBoolEquals(false) + fields["downloadURL"]?.assertStringEquals( + "https://download.developer.apple.com/Developer_Tools/Xcode_15.1/Xcode_15.1.xip" + ) + fields["fileSize"]?.assertInt64Equals(8_000_000_000) + fields["releaseDate"]?.assertIsDate() + + // References + fields["minimumMacOS"]?.assertReferenceEquals("RestoreImage-23C71") + fields["includedSwiftVersion"]?.assertReferenceEquals("SwiftVersion-5.9.2") + + // Optional fields + fields["sdkVersions"]?.assertStringEquals( + #"{"macOS":"14.2","iOS":"17.2","watchOS":"10.2","tvOS":"17.2"}"# + ) + fields["notes"]?.assertStringEquals( + "Release notes: https://developer.apple.com/xcode/release-notes/" + ) + } + + @Test("Convert beta record to CloudKit fields") + internal func testToCloudKitFieldsBeta() { + let record = TestFixtures.xcode160Beta + let fields = record.toCloudKitFields() + + fields["version"]?.assertStringEquals("16.0 Beta 1") + fields["buildNumber"]?.assertStringEquals("16A5171c") + fields["isPrerelease"]?.assertBoolEquals(true) + + // Beta has nil optional fields + #expect(fields["downloadURL"] == nil) + #expect(fields["fileSize"] == nil) + #expect(fields["sdkVersions"] == nil) + #expect(fields["notes"] == nil) + } + + @Test("Roundtrip conversion preserves data") + internal func testRoundtripConversion() { + let original = TestFixtures.xcode151 + let fields = original.toCloudKitFields() + let recordInfo = MockRecordInfo.create( + recordType: "XcodeVersion", + recordName: original.recordName, + fields: fields + ) + + let reconstructed = XcodeVersionRecord.from(recordInfo: recordInfo) + + #expect(reconstructed != nil) + #expect(reconstructed?.version == original.version) + #expect(reconstructed?.buildNumber == original.buildNumber) + #expect(reconstructed?.isPrerelease == original.isPrerelease) + #expect(reconstructed?.downloadURL == original.downloadURL) + #expect(reconstructed?.fileSize == original.fileSize) + #expect(reconstructed?.minimumMacOS == original.minimumMacOS) + #expect(reconstructed?.includedSwiftVersion == original.includedSwiftVersion) + #expect(reconstructed?.sdkVersions == original.sdkVersions) + #expect(reconstructed?.notes == original.notes) + } + + @Test("From RecordInfo with missing required fields returns nil") + internal func testFromRecordInfoMissingFields() { + let recordInfo = MockRecordInfo.create( + recordType: "XcodeVersion", + recordName: "test", + fields: [ + "version": .string("15.1") + // Missing buildNumber and releaseDate + ] + ) + + #expect(XcodeVersionRecord.from(recordInfo: recordInfo) == nil) + } + + @Test("RecordName generation format") + internal func testRecordNameFormat() { + #expect(TestFixtures.xcode151.recordName == "XcodeVersion-15C65") + #expect(TestFixtures.xcode160Beta.recordName == "XcodeVersion-16A5171c") + } + + @Test("CloudKit record type is correct") + internal func testCloudKitRecordType() { + #expect(XcodeVersionRecord.cloudKitRecordType == "XcodeVersion") + } + + @Test("Reference fields are optional") + internal func testOptionalReferences() { + let record = TestFixtures.minimalXcode + let fields = record.toCloudKitFields() + + #expect(fields["minimumMacOS"] == nil) + #expect(fields["includedSwiftVersion"] == nil) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift new file mode 100644 index 00000000..822cdc5f --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift @@ -0,0 +1,100 @@ +// +// FieldValueAssertions.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit +import Testing + +/// Custom assertions for FieldValue comparisons +extension FieldValue { + /// Asserts that this FieldValue is a string with the expected value + public func assertStringEquals(_ expected: String) { + guard case .string(let actual) = self else { + Issue.record("Expected .string, got \(self)") + return + } + #expect(actual == expected) + } + + /// Asserts that this FieldValue is an int64 with the expected value + public func assertInt64Equals(_ expected: Int) { + guard case .int64(let actual) = self else { + Issue.record("Expected .int64, got \(self)") + return + } + #expect(actual == expected) + } + + /// Asserts that this FieldValue is a double with the expected value + public func assertDoubleEquals(_ expected: Double) { + guard case .double(let actual) = self else { + Issue.record("Expected .double, got \(self)") + return + } + #expect(actual == expected) + } + + /// Asserts that this FieldValue is a boolean stored as INT64 (0 or 1) + public func assertBoolEquals(_ expected: Bool) { + // Boolean is stored as INT64 (0 or 1) in CloudKit + guard case .int64(let actual) = self else { + Issue.record("Expected .int64 (boolean), got \(self)") + return + } + let actualBool = actual == 1 + #expect(actualBool == expected) + } + + /// Asserts that this FieldValue is a reference with the expected record name + public func assertReferenceEquals(_ expectedRecordName: String) { + guard case .reference(let ref) = self else { + Issue.record("Expected .reference, got \(self)") + return + } + #expect(ref.recordName == expectedRecordName) + } + + /// Asserts that this FieldValue is a date (does not validate the exact value) + public func assertIsDate() { + guard case .date = self else { + Issue.record("Expected .date, got \(self)") + return + } + } + + /// Asserts that this FieldValue is a date with the expected value + public func assertDateEquals(_ expected: Date) { + guard case .date(let actual) = self else { + Issue.record("Expected .date, got \(self)") + return + } + // Compare timestamps with 1-second tolerance to account for precision differences + #expect(abs(actual.timeIntervalSince(expected)) < 1.0) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/MockRecordInfo.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/MockRecordInfo.swift new file mode 100644 index 00000000..03acc4c6 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/MockRecordInfo.swift @@ -0,0 +1,78 @@ +// +// MockRecordInfo.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit + +/// Helper to create RecordInfo from field dictionaries for testing roundtrips +public enum MockRecordInfo: Sendable { + /// Creates a RecordInfo with the specified fields for testing + /// + /// - Parameters: + /// - recordType: CloudKit record type (e.g., "RestoreImage") + /// - recordName: CloudKit record name (e.g., "RestoreImage-23C71") + /// - fields: Dictionary of CloudKit field values + /// - Returns: A RecordInfo suitable for testing `from(recordInfo:)` methods + public static func create( + recordType: String, + recordName: String, + fields: [String: FieldValue] + ) -> RecordInfo { + RecordInfo( + recordName: recordName, + recordType: recordType, + recordChangeTag: nil, + fields: fields + ) + } + + /// Creates a RecordInfo with an error for testing error handling + /// + /// - Parameters: + /// - recordName: CloudKit record name + /// - errorCode: Server error code (stored in fields for test verification) + /// - reason: Error reason message (stored in fields for test verification) + /// - Returns: A RecordInfo marked as an error (isError == true) + public static func createError( + recordType _: String, + recordName: String, + errorCode: String, + reason: String + ) -> RecordInfo { + RecordInfo( + recordName: recordName, + recordType: "Unknown", // Marks this as an error (isError will be true) + recordChangeTag: nil, + fields: [ + "serverErrorCode": .string(errorCode), + "reason": .string(reason), + ] + ) + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/TestFixtures.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/TestFixtures.swift new file mode 100644 index 00000000..2f755e86 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/TestFixtures.swift @@ -0,0 +1,507 @@ +// +// TestFixtures.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +@testable public import BushelCloudKit +@testable public import BushelFoundation + +/// Centralized test data fixtures for all record types +internal enum TestFixtures: Sendable { + // MARK: - RestoreImage Fixtures + + /// Stable macOS 14.2.1 release (Sonoma) + internal static let sonoma1421 = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), // Dec 12, 2023 + downloadURL: url("https://updates.cdn-apple.com/2023/macos/23C71/RestoreImage.ipsw"), + fileSize: 13_500_000_000, + sha256Hash: "abc123def456789abcdef0123456789abcdef0123456789abcdef0123456789ab", + sha1Hash: "def4567890123456789abcdef01234567890", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: "Stable release for macOS Sonoma", + sourceUpdatedAt: Date(timeIntervalSince1970: 1_702_339_200) + ) + + /// Beta macOS 15.0 (Sequoia) + internal static let sequoia150Beta = RestoreImageRecord( + version: "15.0 Beta 3", + buildNumber: "24A5264n", + releaseDate: Date(timeIntervalSince1970: 1_720_000_000), // Jul 3, 2024 + downloadURL: url("https://updates.cdn-apple.com/2024/macos/24A5264n/RestoreImage.ipsw"), + fileSize: 14_000_000_000, + sha256Hash: "xyz789uvw012345xyzvuwxyz789012345xyzvuwxyz789012345xyzvuwxyz789", + sha1Hash: "uvw0123456789abcdef0123456789abcdef01", + isSigned: false, + isPrerelease: true, + source: "mrmacintosh.com", + notes: "Beta release - unsigned", + sourceUpdatedAt: nil + ) + + /// Minimal RestoreImage with no optional fields + internal static let minimalRestoreImage = RestoreImageRecord( + version: "14.0", + buildNumber: "23A344", + releaseDate: Date(timeIntervalSince1970: 1_695_657_600), // Sep 26, 2023 + downloadURL: url("https://updates.cdn-apple.com/2023/macos/23A344/RestoreImage.ipsw"), + fileSize: 13_000_000_000, + sha256Hash: "minimal123456789abcdef0123456789abcdef0123456789abcdef0123456789ab", + sha1Hash: "minimal456789abcdef0123456789abcdef0", + isSigned: nil, + isPrerelease: false, + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: nil + ) + + // MARK: - XcodeVersion Fixtures + + /// Xcode 15.1 stable release + internal static let xcode151 = XcodeVersionRecord( + version: "15.1", + buildNumber: "15C65", + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), // Dec 12, 2023 + downloadURL: URL( + string: "https://download.developer.apple.com/Developer_Tools/Xcode_15.1/Xcode_15.1.xip" + ), + fileSize: 8_000_000_000, + isPrerelease: false, + minimumMacOS: "RestoreImage-23C71", + includedSwiftVersion: "SwiftVersion-5.9.2", + sdkVersions: #"{"macOS":"14.2","iOS":"17.2","watchOS":"10.2","tvOS":"17.2"}"#, + notes: "Release notes: https://developer.apple.com/xcode/release-notes/" + ) + + /// Xcode 16.0 beta + internal static let xcode160Beta = XcodeVersionRecord( + version: "16.0 Beta 1", + buildNumber: "16A5171c", + releaseDate: Date(timeIntervalSince1970: 1_717_977_600), // Jun 10, 2024 + downloadURL: nil, + fileSize: nil, + isPrerelease: true, + minimumMacOS: "RestoreImage-24A5264n", + includedSwiftVersion: "SwiftVersion-6.0", + sdkVersions: nil, + notes: nil + ) + + /// Minimal Xcode with no optional fields + internal static let minimalXcode = XcodeVersionRecord( + version: "15.0", + buildNumber: "15A240d", + releaseDate: Date(timeIntervalSince1970: 1_695_657_600), + downloadURL: nil, + fileSize: nil, + isPrerelease: false, + minimumMacOS: nil, + includedSwiftVersion: nil, + sdkVersions: nil, + notes: nil + ) + + // MARK: - SwiftVersion Fixtures + + /// Swift 5.9.2 stable + internal static let swift592 = SwiftVersionRecord( + version: "5.9.2", + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), // Dec 12, 2023 + downloadURL: URL( + string: "https://download.swift.org/swift-5.9.2-release/xcode/swift-5.9.2-RELEASE-osx.pkg" + ), + isPrerelease: false, + notes: "Stable Swift release bundled with Xcode 15.1" + ) + + /// Swift 6.0 development snapshot + internal static let swift60Snapshot = SwiftVersionRecord( + version: "6.0", + releaseDate: Date(timeIntervalSince1970: 1_717_977_600), // Jun 10, 2024 + downloadURL: nil, + isPrerelease: true, + notes: nil + ) + + /// Minimal Swift version + internal static let minimalSwift = SwiftVersionRecord( + version: "5.9", + releaseDate: Date(timeIntervalSince1970: 1_695_657_600), + downloadURL: nil, + isPrerelease: false, + notes: nil + ) + + // MARK: - DataSourceMetadata Fixtures + + /// IPSW.me metadata - successful fetch + internal static let metadataIPSWSuccess = DataSourceMetadata( + sourceName: "ipsw.me", + recordTypeName: "RestoreImage", + lastFetchedAt: Date(timeIntervalSince1970: 1_702_339_200), + sourceUpdatedAt: Date(timeIntervalSince1970: 1_702_300_000), + recordCount: 42, + fetchDurationSeconds: 3.5, + lastError: nil + ) + + /// AppleDB metadata - with error + internal static let metadataAppleDBError = DataSourceMetadata( + sourceName: "appledb.dev", + recordTypeName: "RestoreImage", + lastFetchedAt: Date(timeIntervalSince1970: 1_702_339_200), + sourceUpdatedAt: nil, + recordCount: 0, + fetchDurationSeconds: 1.2, + lastError: "HTTP 404: Not Found" + ) + + /// Xcode Releases metadata + internal static let metadataXcodeReleases = DataSourceMetadata( + sourceName: "xcodereleases.com", + recordTypeName: "XcodeVersion", + lastFetchedAt: Date(timeIntervalSince1970: 1_702_339_200), + sourceUpdatedAt: Date(timeIntervalSince1970: 1_702_320_000), + recordCount: 18, + fetchDurationSeconds: 2.1, + lastError: nil + ) + + /// Minimal metadata + internal static let minimalMetadata = DataSourceMetadata( + sourceName: "swift.org", + recordTypeName: "SwiftVersion", + lastFetchedAt: Date(timeIntervalSince1970: 1_702_339_200), + sourceUpdatedAt: nil, + recordCount: 0, + fetchDurationSeconds: 0, + lastError: nil + ) + + // MARK: - Deduplication Test Fixtures + + // MARK: RestoreImage Merge Scenarios + + /// Same build as sonoma1421, MESU source (authoritative for isSigned) + internal static let sonoma1421Mesu = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", // Same as sonoma1421 + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), + downloadURL: url("https://mesu.apple.com/assets/macos/23C71/RestoreImage.ipsw"), + fileSize: 0, // MESU doesn't provide fileSize + sha256Hash: "", // MESU doesn't provide hashes + sha1Hash: "", + isSigned: false, // MESU authority: unsigned + isPrerelease: false, + source: "mesu.apple.com", + notes: "MESU signing status", + sourceUpdatedAt: Date(timeIntervalSince1970: 1_702_400_000) // Later than sonoma1421 + ) + + /// Same build as sonoma1421, AppleDB source with hashes + internal static let sonoma1421Appledb = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", // Same as sonoma1421 + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), + downloadURL: url("https://updates.cdn-apple.com/2023/macos/23C71/RestoreImage.ipsw"), + fileSize: 13_500_000_000, + sha256Hash: "different789hash456123different789hash456123different789hash456123diff", + sha1Hash: "appledb1234567890123456789abcdef0", + isSigned: true, // Conflicts with MESU + isPrerelease: false, + source: "appledb.dev", + notes: "AppleDB record", + sourceUpdatedAt: Date(timeIntervalSince1970: 1_702_350_000) // Between ipsw.me and MESU + ) + + /// Same build as sonoma1421, incomplete data (missing hashes) + internal static let sonoma1421Incomplete = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), + downloadURL: url("https://updates.cdn-apple.com/2023/macos/23C71/RestoreImage.ipsw"), + fileSize: 0, // Missing + sha256Hash: "", // Missing + sha1Hash: "", // Missing + isSigned: nil, + isPrerelease: false, + source: "test-source", + notes: nil, + sourceUpdatedAt: nil + ) + + /// Sequoia 15.1 for sorting tests (newer) + internal static let sequoia151 = RestoreImageRecord( + version: "15.1", + buildNumber: "24B83", + releaseDate: Date(timeIntervalSince1970: 1_730_000_000), // Nov 2024 + downloadURL: url("https://updates.cdn-apple.com/2024/macos/24B83/RestoreImage.ipsw"), + fileSize: 14_500_000_000, + sha256Hash: "sequoia123456789abcdef0123456789abcdef0123456789abcdef0123456789", + sha1Hash: "sequoia456789abcdef0123456789abcdef", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: "Sequoia 15.1", + sourceUpdatedAt: Date(timeIntervalSince1970: 1_730_000_000) + ) + + /// Sonoma 14.0 for sorting tests (older) + internal static let sonoma140 = RestoreImageRecord( + version: "14.0", + buildNumber: "23A344", + releaseDate: Date(timeIntervalSince1970: 1_695_657_600), // Sep 2023 + downloadURL: url("https://updates.cdn-apple.com/2023/macos/23A344/RestoreImage.ipsw"), + fileSize: 13_000_000_000, + sha256Hash: "sonoma14hash123456789abcdef0123456789abcdef0123456789abcdef012", + sha1Hash: "sonoma14hash456789abcdef012345678", + isSigned: false, + isPrerelease: false, + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: nil + ) + + /// Record with isSigned=true, old timestamp + internal static let signedOld = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: url("https://updates.cdn-apple.com/2024/macos/23D56/RestoreImage.ipsw"), + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: Date(timeIntervalSince1970: 1_705_000_000) // Older + ) + + /// Record with isSigned=false, newer timestamp (should win) + internal static let unsignedNewer = RestoreImageRecord( + version: "14.3", + buildNumber: "23D56", // Same build as signedOld + releaseDate: Date(timeIntervalSince1970: 1_705_000_000), + downloadURL: url("https://updates.cdn-apple.com/2024/macos/23D56/RestoreImage.ipsw"), + fileSize: 13_600_000_000, + sha256Hash: "hash123", + sha1Hash: "hash456", + isSigned: false, + isPrerelease: false, + source: "appledb.dev", + notes: nil, + sourceUpdatedAt: Date(timeIntervalSince1970: 1_706_000_000) // Newer + ) + + /// RestoreImage for version 14.2 (matches 14.2 and 14.2.x) + internal static let restoreImage142 = RestoreImageRecord( + version: "14.2", + buildNumber: "23C64", + releaseDate: Date(timeIntervalSince1970: 1_700_000_000), + downloadURL: url("https://updates.cdn-apple.com/2023/macos/23C64/RestoreImage.ipsw"), + fileSize: 13_400_000_000, + sha256Hash: "hash142", + sha1Hash: "sha142", + isSigned: true, + isPrerelease: false, + source: "ipsw.me", + notes: nil, + sourceUpdatedAt: nil + ) + + // MARK: XcodeVersion Reference Resolution Fixtures + + /// Xcode with REQUIRES in notes (to be resolved) + internal static let xcodeWithRequires142 = XcodeVersionRecord( + version: "15.1", + buildNumber: "15C65", + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), + downloadURL: nil, + fileSize: nil, + isPrerelease: false, + minimumMacOS: nil, // Will be resolved + includedSwiftVersion: nil, + sdkVersions: nil, + notes: "REQUIRES:macOS 14.2|NOTES_URL:https://developer.apple.com/notes" + ) + + /// Xcode with REQUIRES using 3-component version + internal static let xcodeWithRequires1421 = XcodeVersionRecord( + version: "15.2", + buildNumber: "15C500b", + releaseDate: Date(timeIntervalSince1970: 1_710_000_000), + downloadURL: nil, + fileSize: nil, + isPrerelease: true, + minimumMacOS: nil, + includedSwiftVersion: nil, + sdkVersions: nil, + notes: "REQUIRES:macOS 14.2.1|NOTES_URL:https://developer.apple.com/beta" + ) + + /// Xcode with no REQUIRES (should remain nil) + internal static let xcodeNoRequires = XcodeVersionRecord( + version: "14.0", + buildNumber: "14A309", + releaseDate: Date(timeIntervalSince1970: 1_660_000_000), + downloadURL: nil, + fileSize: nil, + isPrerelease: false, + minimumMacOS: nil, + includedSwiftVersion: nil, + sdkVersions: nil, + notes: nil + ) + + /// Xcode with unparseable REQUIRES + internal static let xcodeInvalidRequires = XcodeVersionRecord( + version: "15.0", + buildNumber: "15A240d", + releaseDate: Date(timeIntervalSince1970: 1_695_657_600), + downloadURL: nil, + fileSize: nil, + isPrerelease: false, + minimumMacOS: nil, + includedSwiftVersion: nil, + sdkVersions: nil, + notes: "REQUIRES:Something invalid|NOTES_URL:https://example.com" + ) + + // MARK: Xcode/Swift Deduplication Fixtures + + /// Duplicate Xcode build (for deduplication) + internal static let xcode151Duplicate = XcodeVersionRecord( + version: "15.1", + buildNumber: "15C65", // Same build as xcode151 + releaseDate: Date(timeIntervalSince1970: 1_702_339_200), + downloadURL: URL(string: "https://different-url.com/Xcode_15.1.xip"), + fileSize: 8_500_000_000, // Different metadata + isPrerelease: false, + minimumMacOS: nil, + includedSwiftVersion: nil, + sdkVersions: nil, + notes: "Duplicate record" + ) + + /// Xcode 16.0 for sorting tests + internal static let xcode160 = XcodeVersionRecord( + version: "16.0", + buildNumber: "16A242d", + releaseDate: Date(timeIntervalSince1970: 1_725_000_000), // Sep 2024 + downloadURL: nil, + fileSize: nil, + isPrerelease: false, + minimumMacOS: nil, + includedSwiftVersion: nil, + sdkVersions: nil, + notes: nil + ) + + /// Duplicate Swift version + internal static let swift592Duplicate = SwiftVersionRecord( + version: "5.9.2", // Same as swift592 + releaseDate: Date(timeIntervalSince1970: 1_702_400_000), // Different date + downloadURL: URL(string: "https://different-swift-url.com/swift-5.9.2.pkg"), + isPrerelease: false, + notes: "Duplicate Swift version" + ) + + /// Swift 6.1 for sorting tests + internal static let swift61 = SwiftVersionRecord( + version: "6.1", + releaseDate: Date(timeIntervalSince1970: 1_730_000_000), // Nov 2024 + downloadURL: nil, + isPrerelease: false, + notes: nil + ) + + // MARK: - VirtualBuddy API Response Fixtures + + /// VirtualBuddy API response for a signed macOS build + internal static let virtualBuddySignedResponse = """ + { + "uuid": "67919BEC-F793-4544-A5E6-152EE435DCA6", + "version": "15.0", + "build": "24A5327a", + "code": 0, + "message": "SUCCESS", + "isSigned": true + } + """ + + /// VirtualBuddy API response for an unsigned macOS build + internal static let virtualBuddyUnsignedResponse = """ + { + "uuid": "02A12F2F-CE0E-4FBF-8155-884B8D9FD5CB", + "version": "15.1", + "build": "24B5024e", + "code": 94, + "message": "This device isn't eligible for the requested build.", + "isSigned": false + } + """ + + /// VirtualBuddy API response for Sonoma 14.2.1 (signed) + internal static let virtualBuddySonoma1421Response = """ + { + "uuid": "A1B2C3D4-E5F6-7890-1234-567890ABCDEF", + "version": "14.2.1", + "build": "23C71", + "code": 0, + "message": "SUCCESS", + "isSigned": true + } + """ + + /// VirtualBuddy API response with build number mismatch + internal static let virtualBuddyBuildMismatchResponse = """ + { + "uuid": "MISMATCH-UUID-1234-5678-9ABC-DEF123456789", + "version": "15.0", + "build": "WRONG_BUILD", + "code": 0, + "message": "SUCCESS", + "isSigned": true + } + """ + + // MARK: - Helpers + + /// Create a URL from a string, force unwrapping for test fixtures + /// Test URLs are known to be valid, so force unwrap is acceptable here + private static func url(_ string: String) -> URL { + // swiftlint:disable:next force_unwrapping + URL(string: string)! + } +} +// swiftlint:enable identifier_name file_length type_body_length diff --git a/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift b/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift new file mode 100644 index 00000000..e3dacfdd --- /dev/null +++ b/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift @@ -0,0 +1,21 @@ +// +// ConfigKeySourceTests.swift +// ConfigKeyKit +// +// Tests for ConfigKeySource enum +// + +import Testing + +@testable import ConfigKeyKit + +@Suite("ConfigKeySource Tests") +internal struct ConfigKeySourceTests { + @Test("All cases") + internal func allCases() { + let sources = ConfigKeySource.allCases + #expect(sources.count == 2) + #expect(sources.contains(.commandLine)) + #expect(sources.contains(.environment)) + } +} diff --git a/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeyTests.swift b/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeyTests.swift new file mode 100644 index 00000000..512510d8 --- /dev/null +++ b/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeyTests.swift @@ -0,0 +1,58 @@ +// +// ConfigKeyTests.swift +// ConfigKeyKit +// +// Tests for ConfigKey configuration +// + +import Testing + +@testable import ConfigKeyKit + +@Suite("ConfigKey Tests") +internal struct ConfigKeyTests { + @Test("ConfigKey with explicit keys and default") + internal func explicitKeys() { + let key = ConfigKey(cli: "test.key", env: "TEST_KEY", default: "default-value") + + #expect(key.key(for: .commandLine) == "test.key") + #expect(key.key(for: .environment) == "TEST_KEY") + #expect(key.defaultValue == "default-value") + } + + @Test("ConfigKey with base string and default prefix") + internal func baseStringWithDefaultPrefix() { + let key = ConfigKey( + bushelPrefixed: "cloudkit.container_id", default: "iCloud.com.example.App" + ) + + #expect(key.key(for: .commandLine) == "cloudkit.container_id") + #expect(key.key(for: .environment) == "BUSHEL_CLOUDKIT_CONTAINER_ID") + #expect(key.defaultValue == "iCloud.com.example.App") + } + + @Test("ConfigKey with base string and no prefix") + internal func baseStringNoPrefix() { + let key = ConfigKey( + "cloudkit.container_id", envPrefix: nil, default: "iCloud.com.example.App" + ) + + #expect(key.key(for: .commandLine) == "cloudkit.container_id") + #expect(key.key(for: .environment) == "CLOUDKIT_CONTAINER_ID") + #expect(key.defaultValue == "iCloud.com.example.App") + } + + @Test("ConfigKey with default value") + internal func defaultValue() { + let key = ConfigKey(cli: "test.key", env: "TEST_KEY", default: "default-value") + + #expect(key.defaultValue == "default-value") + } + + @Test("Boolean ConfigKey with default") + internal func booleanDefaultValue() { + let key = ConfigKey(bushelPrefixed: "sync.verbose", default: false) + + #expect(key.defaultValue == false) + } +} diff --git a/Examples/BushelCloud/Tests/ConfigKeyKitTests/NamingStyleTests.swift b/Examples/BushelCloud/Tests/ConfigKeyKitTests/NamingStyleTests.swift new file mode 100644 index 00000000..e45dca0a --- /dev/null +++ b/Examples/BushelCloud/Tests/ConfigKeyKitTests/NamingStyleTests.swift @@ -0,0 +1,37 @@ +// +// NamingStyleTests.swift +// ConfigKeyKit +// +// Tests for naming style transformations +// + +import Testing + +@testable import ConfigKeyKit + +@Suite("NamingStyle Tests") +internal struct NamingStyleTests { + @Test("Dot-separated style") + internal func dotSeparatedStyle() { + let style = StandardNamingStyle.dotSeparated + #expect(style.transform("cloudkit.container_id") == "cloudkit.container_id") + } + + @Test("Screaming snake case with prefix") + internal func screamingSnakeCaseWithPrefix() { + let style = StandardNamingStyle.screamingSnakeCase(prefix: "BUSHEL") + #expect(style.transform("cloudkit.container_id") == "BUSHEL_CLOUDKIT_CONTAINER_ID") + } + + @Test("Screaming snake case without prefix") + internal func screamingSnakeCaseNoPrefix() { + let style = StandardNamingStyle.screamingSnakeCase(prefix: nil) + #expect(style.transform("cloudkit.container_id") == "CLOUDKIT_CONTAINER_ID") + } + + @Test("Screaming snake case with nil prefix") + internal func screamingSnakeCaseNilPrefix() { + let style = StandardNamingStyle.screamingSnakeCase(prefix: nil) + #expect(style.transform("sync.verbose") == "SYNC_VERBOSE") + } +} diff --git a/Examples/BushelCloud/Tests/ConfigKeyKitTests/OptionalConfigKeyTests.swift b/Examples/BushelCloud/Tests/ConfigKeyKitTests/OptionalConfigKeyTests.swift new file mode 100644 index 00000000..3daac28f --- /dev/null +++ b/Examples/BushelCloud/Tests/ConfigKeyKitTests/OptionalConfigKeyTests.swift @@ -0,0 +1,62 @@ +// +// OptionalConfigKeyTests.swift +// ConfigKeyKit +// +// Tests for OptionalConfigKey configuration +// + +import Testing + +@testable import ConfigKeyKit + +@Suite("OptionalConfigKey Tests") +internal struct OptionalConfigKeyTests { + @Test("OptionalConfigKey with explicit keys") + internal func explicitKeys() { + let key = OptionalConfigKey(cli: "test.key", env: "TEST_KEY") + + #expect(key.key(for: .commandLine) == "test.key") + #expect(key.key(for: .environment) == "TEST_KEY") + } + + @Test("OptionalConfigKey with base string and default prefix") + internal func baseStringWithDefaultPrefix() { + let key = OptionalConfigKey(bushelPrefixed: "cloudkit.key_id") + + #expect(key.key(for: .commandLine) == "cloudkit.key_id") + #expect(key.key(for: .environment) == "BUSHEL_CLOUDKIT_KEY_ID") + } + + @Test("OptionalConfigKey with base string and no prefix") + internal func baseStringNoPrefix() { + let key = OptionalConfigKey("cloudkit.key_id", envPrefix: nil) + + #expect(key.key(for: .commandLine) == "cloudkit.key_id") + #expect(key.key(for: .environment) == "CLOUDKIT_KEY_ID") + } + + @Test("OptionalConfigKey and ConfigKey generate identical keys") + internal func keyGenerationParity() { + let optional = OptionalConfigKey(bushelPrefixed: "test.key") + let withDefault = ConfigKey(bushelPrefixed: "test.key", default: "default") + + #expect(optional.key(for: .commandLine) == withDefault.key(for: .commandLine)) + #expect(optional.key(for: .environment) == withDefault.key(for: .environment)) + } + + @Test("OptionalConfigKey for Int type") + internal func intOptionalKey() { + let key = OptionalConfigKey(bushelPrefixed: "sync.min_interval") + + #expect(key.key(for: .commandLine) == "sync.min_interval") + #expect(key.key(for: .environment) == "BUSHEL_SYNC_MIN_INTERVAL") + } + + @Test("OptionalConfigKey for Double type") + internal func doubleOptionalKey() { + let key = OptionalConfigKey(bushelPrefixed: "fetch.interval_global") + + #expect(key.key(for: .commandLine) == "fetch.interval_global") + #expect(key.key(for: .environment) == "BUSHEL_FETCH_INTERVAL_GLOBAL") + } +} diff --git a/Examples/BushelCloud/codecov.yml b/Examples/BushelCloud/codecov.yml new file mode 100644 index 00000000..951b97b9 --- /dev/null +++ b/Examples/BushelCloud/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests" diff --git a/Examples/BushelCloud/project.yml b/Examples/BushelCloud/project.yml new file mode 100644 index 00000000..946816d7 --- /dev/null +++ b/Examples/BushelCloud/project.yml @@ -0,0 +1,13 @@ +name: BushelCloud +settings: + LINT_MODE: ${LINT_MODE} +packages: + BushelCloud: + path: . +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {} diff --git a/Examples/Bushel/schema.ckdb b/Examples/BushelCloud/schema.ckdb similarity index 97% rename from Examples/Bushel/schema.ckdb rename to Examples/BushelCloud/schema.ckdb index 8f64548f..fc2166c2 100644 --- a/Examples/Bushel/schema.ckdb +++ b/Examples/BushelCloud/schema.ckdb @@ -13,6 +13,7 @@ RECORD TYPE RestoreImage ( "isPrerelease" INT64 QUERYABLE, "source" STRING, "notes" STRING, + "sourceUpdatedAt" TIMESTAMP QUERYABLE SORTABLE, GRANT READ, CREATE, WRITE TO "_creator", GRANT READ, CREATE, WRITE TO "_icloud", diff --git a/Examples/Celestra/.env.example b/Examples/Celestra/.env.example deleted file mode 100644 index 0ffebfc5..00000000 --- a/Examples/Celestra/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# CloudKit Configuration -# Copy this file to .env and fill in your values - -# Your CloudKit container ID (e.g., iCloud.com.brightdigit.Celestra) -CLOUDKIT_CONTAINER_ID=iCloud.com.brightdigit.Celestra - -# Your CloudKit server-to-server key ID from Apple Developer Console -CLOUDKIT_KEY_ID=your-key-id-here - -# Path to your CloudKit private key PEM file -CLOUDKIT_PRIVATE_KEY_PATH=/path/to/eckey.pem - -# CloudKit environment: development or production -CLOUDKIT_ENVIRONMENT=development diff --git a/Examples/Celestra/BUSHEL_PATTERNS.md b/Examples/Celestra/BUSHEL_PATTERNS.md deleted file mode 100644 index 57840e84..00000000 --- a/Examples/Celestra/BUSHEL_PATTERNS.md +++ /dev/null @@ -1,656 +0,0 @@ -# Bushel Patterns: CloudKit Integration Reference - -This document captures the CloudKit integration patterns used in the Bushel example project, serving as a reference for understanding MistKit's capabilities and design approaches. - -## Table of Contents - -- [Overview](#overview) -- [CloudKitRecord Protocol Pattern](#cloudkitrecord-protocol-pattern) -- [Schema Design Patterns](#schema-design-patterns) -- [Server-to-Server Authentication](#server-to-server-authentication) -- [Batch Operations](#batch-operations) -- [Relationship Handling](#relationship-handling) -- [Data Pipeline Architecture](#data-pipeline-architecture) -- [Celestra vs Bushel Comparison](#celestra-vs-bushel-comparison) - -## Overview - -Bushel is a production example demonstrating MistKit's CloudKit integration for syncing macOS software version data. It showcases advanced patterns including: - -- Protocol-oriented CloudKit record management -- Complex relationship handling between multiple record types -- Parallel data fetching from multiple sources -- Deduplication strategies -- Comprehensive error handling - -Location: `Examples/Bushel/` - -## CloudKitRecord Protocol Pattern - -### The Protocol - -Bushel uses a protocol-based approach for CloudKit record conversion: - -```swift -protocol CloudKitRecord { - static var cloudKitRecordType: String { get } - var recordName: String { get } - - func toCloudKitFields() -> [String: FieldValue] - static func from(recordInfo: RecordInfo) -> Self? - static func formatForDisplay(_ recordInfo: RecordInfo) -> String -} -``` - -### Implementation Example - -```swift -struct RestoreImageRecord: CloudKitRecord { - static var cloudKitRecordType: String { "RestoreImage" } - - var recordName: String { - "RestoreImage-\(buildNumber)" // Stable, deterministic ID - } - - func toCloudKitFields() -> [String: FieldValue] { - var fields: [String: FieldValue] = [ - "version": .string(version), - "buildNumber": .string(buildNumber), - "releaseDate": .date(releaseDate), - "fileSize": .int64(fileSize), - "isPrerelease": .boolean(isPrerelease) - ] - - // Handle optional fields - if let isSigned { - fields["isSigned"] = .boolean(isSigned) - } - - // Handle relationships - if let minimumMacOSRecordName { - fields["minimumMacOS"] = .reference( - FieldValue.Reference(recordName: minimumMacOSRecordName) - ) - } - - return fields - } - - static func from(recordInfo: RecordInfo) -> Self? { - guard let version = recordInfo.fields["version"]?.stringValue, - let buildNumber = recordInfo.fields["buildNumber"]?.stringValue - else { return nil } - - let releaseDate = recordInfo.fields["releaseDate"]?.dateValue ?? Date() - let fileSize = recordInfo.fields["fileSize"]?.int64Value ?? 0 - - return RestoreImageRecord( - version: version, - buildNumber: buildNumber, - releaseDate: releaseDate, - fileSize: fileSize, - // ... other fields - ) - } -} -``` - -### Benefits - -1. **Type Safety**: Compiler-enforced conversion methods -2. **Reusability**: Generic CloudKit operations work with any `CloudKitRecord` -3. **Testability**: Easy to unit test conversions independently -4. **Maintainability**: Single source of truth for field mapping - -### Generic Sync Pattern - -```swift -extension RecordManaging { - func sync(_ records: [T]) async throws { - let operations = records.map { record in - RecordOperation( - operationType: .forceReplace, - recordType: T.cloudKitRecordType, - recordName: record.recordName, - fields: record.toCloudKitFields() - ) - } - - try await executeBatchOperations(operations, recordType: T.cloudKitRecordType) - } -} -``` - -## Schema Design Patterns - -### Schema File Format - -```text -DEFINE SCHEMA - -RECORD TYPE RestoreImage ( - "version" STRING QUERYABLE SORTABLE SEARCHABLE, - "buildNumber" STRING QUERYABLE SORTABLE, - "releaseDate" TIMESTAMP QUERYABLE SORTABLE, - "fileSize" INT64, - "isSigned" INT64 QUERYABLE, # Boolean as INT64 - "minimumMacOS" REFERENCE, # Relationship - - GRANT READ, CREATE, WRITE TO "_creator", - GRANT READ, CREATE, WRITE TO "_icloud", - GRANT READ TO "_world" # Public read access -); -``` - -### Key Principles - -1. **Always include `DEFINE SCHEMA` header** - Required by `cktool` -2. **Never include system fields** - `__recordID`, `___createTime`, etc. are automatic -3. **Use INT64 for booleans** - CloudKit doesn't have native boolean type -4. **Use REFERENCE for relationships** - Links between record types -5. **Mark query fields appropriately**: - - `QUERYABLE` - Can filter on this field - - `SORTABLE` - Can order results by this field - - `SEARCHABLE` - Enable full-text search - -6. **Set appropriate permissions**: - - `_creator` - Record owner (read/write) - - `_icloud` - Authenticated iCloud users - - `_world` - Public (read-only typically) - -### Indexing Strategy - -```swift -// Fields you'll query on -"buildNumber" STRING QUERYABLE // WHERE buildNumber = "21A5522h" -"releaseDate" TIMESTAMP QUERYABLE SORTABLE // ORDER BY releaseDate DESC -"version" STRING SEARCHABLE // Full-text search -``` - -### Automated Schema Deployment - -Bushel includes `Scripts/setup-cloudkit-schema.sh`: - -```bash -#!/bin/bash -set -euo pipefail - -CONTAINER_ID="${CLOUDKIT_CONTAINER_ID}" -MANAGEMENT_TOKEN="${CLOUDKIT_MANAGEMENT_TOKEN}" -ENVIRONMENT="${CLOUDKIT_ENVIRONMENT:-development}" - -cktool -t "$MANAGEMENT_TOKEN" \ - -c "$CONTAINER_ID" \ - -e "$ENVIRONMENT" \ - import-schema schema.ckdb -``` - -## Server-to-Server Authentication - -### Setup Process - -1. **Generate CloudKit Key** (Apple Developer portal): - - Navigate to Certificates, Identifiers & Profiles - - Keys → CloudKit Web Service - - Download `.p8` file and note Key ID - -2. **Secure Key Storage**: -```bash -mkdir -p ~/.cloudkit -chmod 700 ~/.cloudkit -mv AuthKey_*.p8 ~/.cloudkit/bushel-private-key.pem -chmod 600 ~/.cloudkit/bushel-private-key.pem -``` - -3. **Environment Variables**: -```bash -export CLOUDKIT_KEY_ID="your_key_id_here" -export CLOUDKIT_KEY_FILE="$HOME/.cloudkit/bushel-private-key.pem" -``` - -### Implementation - -```swift -// Read private key from disk -let pemString = try String( - contentsOfFile: privateKeyPath, - encoding: .utf8 -) - -// Create authentication manager -let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: pemString -) - -// Create CloudKit service -let service = try CloudKitService( - containerIdentifier: "iCloud.com.company.App", - tokenManager: tokenManager, - environment: .development, - database: .public -) -``` - -### Security Best Practices - -- ✅ Never commit `.p8` or `.pem` files to version control -- ✅ Store keys with restricted permissions (600) -- ✅ Use environment variables for key paths -- ✅ Use different keys for development vs production -- ✅ Rotate keys periodically -- ❌ Never hardcode keys in source code -- ❌ Never share keys across projects - -## Batch Operations - -### CloudKit Limits - -- **Maximum 200 operations per request** -- **Maximum 400 operations per transaction** -- **Rate limits apply per container** - -### Batching Pattern - -```swift -func executeBatchOperations( - _ operations: [RecordOperation], - recordType: String -) async throws { - let batchSize = 200 - let batches = operations.chunked(into: batchSize) - - for (index, batch) in batches.enumerated() { - print(" Batch \(index + 1)/\(batches.count)...") - - let results = try await service.modifyRecords(batch) - - // Handle partial failures - let successful = results.filter { !$0.isError } - let failed = results.count - successful.count - - if failed > 0 { - print(" ⚠️ \(failed) operations failed") - - // Log specific failures - for result in results where result.isError { - if let error = result.error { - print(" Error: \(error.localizedDescription)") - } - } - } - } -} -``` - -### Non-Atomic Operations - -```swift -let operations = articles.map { article in - RecordOperation( - operationType: .create, - recordType: "PublicArticle", - recordName: article.recordName, - fields: article.toFieldsDict() - ) -} - -// Non-atomic: partial success possible -let results = try await service.modifyRecords(operations) - -// Check individual results -for (index, result) in results.enumerated() { - if result.isError { - print("Article \(index) failed: \(result.error?.localizedDescription ?? "Unknown")") - } -} -``` - -## Relationship Handling - -### Schema Definition - -```text -RECORD TYPE XcodeVersion ( - "version" STRING QUERYABLE, - "releaseDate" TIMESTAMP QUERYABLE SORTABLE, - "minimumMacOS" REFERENCE, # → RestoreImage - "requiredSwift" REFERENCE # → SwiftVersion -); - -RECORD TYPE RestoreImage ( - "buildNumber" STRING QUERYABLE, - ... -); -``` - -### Using References in Code - -```swift -// Create reference field -let minimumMacOSRef = FieldValue.Reference( - recordName: "RestoreImage-21A5522h" -) - -fields["minimumMacOS"] = .reference(minimumMacOSRef) -``` - -### Syncing Order (Respecting Dependencies) - -```swift -// 1. Sync independent records first -try await sync(swiftVersions) -try await sync(restoreImages) - -// 2. Then sync records with dependencies -try await sync(xcodeVersions) // References swift/restore images -``` - -### Querying Relationships - -```swift -// Query Xcode versions with specific macOS requirement -let filter = QueryFilter.equals( - "minimumMacOS", - .reference(FieldValue.Reference(recordName: "RestoreImage-21A5522h")) -) - -let results = try await service.queryRecords( - recordType: "XcodeVersion", - filters: [filter] -) -``` - -## Data Pipeline Architecture - -### Multi-Source Fetching - -```swift -struct DataSourcePipeline: Sendable { - func fetch(options: Options) async throws -> FetchResult { - // Parallel fetching with structured concurrency - async let ipswImages = IPSWFetcher().fetch() - async let appleDBImages = AppleDBFetcher().fetch() - async let xcodeVersions = XcodeReleaseFetcher().fetch() - - // Collect all results - var allImages = try await ipswImages - allImages.append(contentsOf: try await appleDBImages) - - // Deduplicate and return - return FetchResult( - restoreImages: deduplicateRestoreImages(allImages), - xcodeVersions: try await xcodeVersions, - swiftVersions: extractSwiftVersions() - ) - } -} -``` - -### Individual Fetcher Pattern - -```swift -protocol DataSourceFetcher: Sendable { - associatedtype Record - func fetch() async throws -> [Record] -} - -struct IPSWFetcher: DataSourceFetcher { - func fetch() async throws -> [RestoreImageRecord] { - let client = IPSWDownloads(transport: URLSessionTransport()) - let device = try await client.device(withIdentifier: "VirtualMac2,1") - - return device.firmwares.map { firmware in - RestoreImageRecord( - version: firmware.version.description, - buildNumber: firmware.buildid, - releaseDate: firmware.releasedate, - fileSize: firmware.filesize, - isSigned: firmware.signed - ) - } - } -} -``` - -### Deduplication Strategy - -```swift -private func deduplicateRestoreImages( - _ images: [RestoreImageRecord] -) -> [RestoreImageRecord] { - var uniqueImages: [String: RestoreImageRecord] = [:] - - for image in images { - let key = image.buildNumber // Unique identifier - - if let existing = uniqueImages[key] { - // Merge records, prefer most complete data - uniqueImages[key] = mergeRestoreImages(existing, image) - } else { - uniqueImages[key] = image - } - } - - return Array(uniqueImages.values) - .sorted { $0.releaseDate > $1.releaseDate } -} - -private func mergeRestoreImages( - _ a: RestoreImageRecord, - _ b: RestoreImageRecord -) -> RestoreImageRecord { - // Prefer non-nil values - RestoreImageRecord( - version: a.version, - buildNumber: a.buildNumber, - releaseDate: a.releaseDate, - fileSize: a.fileSize ?? b.fileSize, - isSigned: a.isSigned ?? b.isSigned, - url: a.url ?? b.url - ) -} -``` - -### Graceful Degradation - -```swift -// Don't fail entire sync if one source fails -var allImages: [RestoreImageRecord] = [] - -do { - let ipswImages = try await IPSWFetcher().fetch() - allImages.append(contentsOf: ipswImages) -} catch { - print(" ⚠️ IPSW fetch failed: \(error)") -} - -do { - let appleDBImages = try await AppleDBFetcher().fetch() - allImages.append(contentsOf: appleDBImages) -} catch { - print(" ⚠️ AppleDB fetch failed: \(error)") -} - -// Continue with whatever data we got -return deduplicateRestoreImages(allImages) -``` - -### Metadata Tracking - -```swift -struct DataSourceMetadata: CloudKitRecord { - let sourceName: String - let recordTypeName: String - let lastFetchedAt: Date - let recordCount: Int - let fetchDurationSeconds: Double - let lastError: String? - - var recordName: String { - "metadata-\(sourceName)-\(recordTypeName)" - } -} - -// Check before fetching -private func shouldFetch( - source: String, - recordType: String, - force: Bool -) async -> Bool { - guard !force else { return true } - - let metadata = try? await cloudKit.queryDataSourceMetadata( - source: source, - recordType: recordType - ) - - guard let existing = metadata else { return true } - - let timeSinceLastFetch = Date().timeIntervalSince(existing.lastFetchedAt) - let minInterval = configuration.minimumInterval(for: source) ?? 3600 - - return timeSinceLastFetch >= minInterval -} -``` - -## Celestra vs Bushel Comparison - -### Architecture Similarities - -| Aspect | Bushel | Celestra | -|--------|---------|----------| -| **Schema Management** | `schema.ckdb` + setup script | `schema.ckdb` + setup script | -| **Authentication** | Server-to-Server (PEM) | Server-to-Server (PEM) | -| **CLI Framework** | ArgumentParser | ArgumentParser | -| **Concurrency** | async/await | async/await | -| **Database** | Public | Public | -| **Documentation** | Comprehensive | Comprehensive | - -### Key Differences - -#### 1. Record Conversion Pattern - -**Bushel (Protocol-Based):** -```swift -protocol CloudKitRecord { - func toCloudKitFields() -> [String: FieldValue] - static func from(recordInfo: RecordInfo) -> Self? -} - -struct RestoreImageRecord: CloudKitRecord { ... } - -// Generic sync -func sync(_ records: [T]) async throws -``` - -**Celestra (Direct Mapping):** -```swift -struct PublicArticle { - func toFieldsDict() -> [String: FieldValue] { ... } - init(from recordInfo: RecordInfo) { ... } -} - -// Specific sync methods -func createArticles(_ articles: [PublicArticle]) async throws -``` - -**Trade-offs:** -- Bushel: More generic, reusable patterns -- Celestra: Simpler, more direct for single-purpose tool - -#### 2. Relationship Handling - -**Bushel (CKReference):** -```swift -fields["minimumMacOS"] = .reference( - FieldValue.Reference(recordName: "RestoreImage-21A5522h") -) -``` - -**Celestra (String-Based):** -```swift -fields["feedRecordName"] = .string(feedRecordName) -``` - -**Trade-offs:** -- Bushel: Type-safe relationships, cascade deletes possible -- Celestra: Simpler querying, manual cascade handling - -#### 3. Data Pipeline Complexity - -**Bushel:** -- Multiple external data sources -- Parallel fetching with `async let` -- Complex deduplication (merge strategies) -- Cross-record relationships (Xcode → Swift, RestoreImage) - -**Celestra:** -- Single data source type (RSS feeds) -- Sequential or parallel feed updates -- Simple deduplication (GUID-based) -- Parent-child relationship only (Feed → Articles) - -#### 4. Deduplication Strategy - -**Bushel:** -```swift -// Merge records from multiple sources -private func mergeRestoreImages( - _ a: RestoreImageRecord, - _ b: RestoreImageRecord -) -> RestoreImageRecord { - // Combine data, prefer most complete -} -``` - -**Celestra (Recommended):** -```swift -// Query existing before upload -let existingArticles = try await queryArticlesByGUIDs(guids, feedRecordName) -let newArticles = articles.filter { article in - !existingArticles.contains { $0.guid == article.guid } -} -``` - -### When to Use Each Pattern - -**Use Bushel's Protocol Pattern When:** -- Multiple record types with similar operations -- Building a reusable framework -- Complex relationship graphs -- Need maximum type safety - -**Use Celestra's Direct Pattern When:** -- Simple, focused tool -- Single or few record types -- Straightforward relationships -- Prioritizing simplicity - -### Common Best Practices (Both Projects) - -1. ✅ **Schema-First Design** - Define `schema.ckdb` before coding -2. ✅ **Automated Setup Scripts** - Script schema deployment -3. ✅ **Server-to-Server Auth** - Use PEM keys, not user auth -4. ✅ **Batch Operations** - Respect 200-record limit -5. ✅ **Error Handling** - Graceful degradation -6. ✅ **Documentation** - Comprehensive README and setup guides -7. ✅ **Environment Variables** - Never hardcode credentials -8. ✅ **Structured Concurrency** - Use async/await throughout - -## Additional Resources - -- **Bushel Source**: `Examples/Bushel/` -- **Celestra Source**: `Examples/Celestra/` -- **MistKit Documentation**: Root README.md -- **CloudKit Web Services**: `.claude/docs/webservices.md` -- **Swift OpenAPI Generator**: `.claude/docs/swift-openapi-generator.md` - -## Conclusion - -Both Bushel and Celestra demonstrate effective CloudKit integration patterns using MistKit, with different trade-offs based on project complexity and requirements. Use this document as a reference when designing CloudKit-backed applications with MistKit. - -For blog posts or tutorials: -- **Beginners**: Start with Celestra's direct approach -- **Advanced**: Explore Bushel's protocol-oriented patterns -- **Production**: Consider adopting patterns from both based on your needs diff --git a/Examples/Celestra/Package.resolved b/Examples/Celestra/Package.resolved deleted file mode 100644 index ada192aa..00000000 --- a/Examples/Celestra/Package.resolved +++ /dev/null @@ -1,95 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", - "version" : "1.6.2" - } - }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" - } - }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types", - "state" : { - "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" - } - }, - { - "identity" : "swift-openapi-runtime", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-openapi-runtime", - "state" : { - "revision" : "7722cf8eac05c1f1b5b05895b04cfcc29576d9be", - "version" : "1.8.3" - } - }, - { - "identity" : "swift-openapi-urlsession", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-openapi-urlsession", - "state" : { - "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", - "version" : "1.2.0" - } - }, - { - "identity" : "syndikit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/brightdigit/SyndiKit.git", - "state" : { - "revision" : "1b7991213a1562bb6d93ffedf58533c06fe626f5", - "version" : "0.6.1" - } - }, - { - "identity" : "xmlcoder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/brightdigit/XMLCoder", - "state" : { - "revision" : "8ba70f27664ea8c8b7f38fb4c6f2fd4c129eb9c5", - "version" : "1.0.0-alpha.1" - } - } - ], - "version" : 2 -} diff --git a/Examples/Celestra/Package.swift b/Examples/Celestra/Package.swift deleted file mode 100644 index 0ddd1e25..00000000 --- a/Examples/Celestra/Package.swift +++ /dev/null @@ -1,104 +0,0 @@ -// swift-tools-version: 6.2 - -// swiftlint:disable explicit_acl explicit_top_level_acl - -import PackageDescription - -// MARK: - Swift Settings Configuration - -let swiftSettings: [SwiftSetting] = [ - // Swift 6.2 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations - .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking - .unsafeFlags([ - // Enable concurrency warnings - "-warn-concurrency", - // Enable actor data race checks - "-enable-actor-data-race-checks", - // Complete strict concurrency checking - "-strict-concurrency=complete", - // Enable testing support - "-enable-testing", - // Warn about functions with >100 lines - "-Xfrontend", "-warn-long-function-bodies=100", - // Warn about slow type checking expressions - "-Xfrontend", "-warn-long-expression-type-checking=100" - ]) -] - -let package = Package( - name: "Celestra", - platforms: [.macOS(.v14)], - products: [ - .executable(name: "celestra", targets: ["Celestra"]) - ], - dependencies: [ - .package(path: "../.."), // MistKit - .package(url: "https://github.com/brightdigit/SyndiKit.git", from: "0.6.0"), - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0") - ], - targets: [ - .executableTarget( - name: "Celestra", - dependencies: [ - .product(name: "MistKit", package: "MistKit"), - .product(name: "SyndiKit", package: "SyndiKit"), - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log") - ], - swiftSettings: swiftSettings - ) - ] -) -// swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Examples/Celestra/README.md b/Examples/Celestra/README.md deleted file mode 100644 index d8d1cf2f..00000000 --- a/Examples/Celestra/README.md +++ /dev/null @@ -1,358 +0,0 @@ -# Celestra - RSS Reader with CloudKit Sync - -Celestra is a command-line RSS reader that demonstrates MistKit's query filtering and sorting features by managing RSS feeds in CloudKit's public database. - -## Features - -- **RSS Parsing with SyndiKit**: Parse RSS and Atom feeds using BrightDigit's SyndiKit library -- **Add RSS Feeds**: Parse and validate RSS feeds, then store metadata in CloudKit -- **Duplicate Detection**: Automatically detect and skip duplicate articles using GUID-based queries -- **Filtered Updates**: Query feeds using MistKit's `QueryFilter` API (by date and popularity) -- **Batch Operations**: Upload multiple articles efficiently using non-atomic operations -- **Server-to-Server Auth**: Demonstrates CloudKit authentication for backend services -- **Record Modification**: Uses MistKit's new public record modification APIs - -## Prerequisites - -1. **Apple Developer Account** with CloudKit access -2. **CloudKit Container** configured in Apple Developer Console -3. **Server-to-Server Key** generated for CloudKit access -4. **Swift 5.9+** and **macOS 13.0+** (required by SyndiKit) - -## CloudKit Setup - -You can set up the CloudKit schema either automatically using `cktool` (recommended) or manually through the CloudKit Dashboard. - -### Option 1: Automated Setup (Recommended) - -Use the provided script to automatically import the schema: - -```bash -# Set your CloudKit credentials -export CLOUDKIT_CONTAINER_ID="iCloud.com.brightdigit.Celestra" -export CLOUDKIT_TEAM_ID="YOUR_TEAM_ID" -export CLOUDKIT_ENVIRONMENT="development" - -# Run the setup script -cd Examples/Celestra -./Scripts/setup-cloudkit-schema.sh -``` - -For detailed instructions, see [CLOUDKIT_SCHEMA_SETUP.md](./CLOUDKIT_SCHEMA_SETUP.md). - -### Option 2: Manual Setup - -#### 1. Create CloudKit Container - -1. Go to [Apple Developer Console](https://developer.apple.com) -2. Navigate to CloudKit Dashboard -3. Create a new container (e.g., `iCloud.com.brightdigit.Celestra`) - -#### 2. Configure Record Types - -In CloudKit Dashboard, create these record types in the **Public Database**: - -#### Feed Record Type -| Field Name | Field Type | Indexed | -|------------|------------|---------| -| feedURL | String | Yes (Queryable, Sortable) | -| title | String | Yes (Searchable) | -| description | String | No | -| totalAttempts | Int64 | No | -| successfulAttempts | Int64 | No | -| usageCount | Int64 | Yes (Queryable, Sortable) | -| lastAttempted | Date/Time | Yes (Queryable, Sortable) | -| isActive | Int64 | Yes (Queryable) | - -#### Article Record Type -| Field Name | Field Type | Indexed | -|------------|------------|---------| -| feedRecordName | String | Yes (Queryable, Sortable) | -| title | String | Yes (Searchable) | -| link | String | No | -| description | String | No | -| author | String | Yes (Queryable) | -| pubDate | Date/Time | Yes (Queryable, Sortable) | -| guid | String | Yes (Queryable, Sortable) | -| contentHash | String | Yes (Queryable) | -| fetchedAt | Date/Time | Yes (Queryable, Sortable) | -| expiresAt | Date/Time | Yes (Queryable, Sortable) | - -#### 3. Generate Server-to-Server Key - -1. In CloudKit Dashboard, go to **API Tokens** -2. Click **Server-to-Server Keys** -3. Generate a new key -4. Download the `.pem` file and save it securely -5. Note the **Key ID** (you'll need this) - -## Installation - -### 1. Clone Repository - -```bash -git clone https://github.com/brightdigit/MistKit.git -cd MistKit/Examples/Celestra -``` - -### 2. Configure Environment - -```bash -# Copy the example environment file -cp .env.example .env - -# Edit .env with your CloudKit credentials -nano .env -``` - -Update `.env` with your values: - -```bash -CLOUDKIT_CONTAINER_ID=iCloud.com.brightdigit.Celestra -CLOUDKIT_KEY_ID=your-key-id-here -CLOUDKIT_PRIVATE_KEY_PATH=/path/to/eckey.pem -CLOUDKIT_ENVIRONMENT=development -``` - -### 3. Build - -```bash -swift build -``` - -## Usage - -Source your environment variables before running commands: - -```bash -source .env -``` - -### Add a Feed - -Add a new RSS feed to CloudKit: - -```bash -swift run celestra add-feed https://example.com/feed.xml -``` - -Example output: -``` -🌐 Fetching RSS feed: https://example.com/feed.xml -✅ Found feed: Example Blog - Articles: 25 -✅ Feed added to CloudKit - Record Name: ABC123-DEF456-GHI789 - Zone: default -``` - -### Update Feeds - -Fetch and update all feeds: - -```bash -swift run celestra update -``` - -Update with filters (demonstrates QueryFilter API): - -```bash -# Update feeds last attempted before a specific date -swift run celestra update --last-attempted-before 2025-01-01T00:00:00Z - -# Update only popular feeds (minimum 10 usage count) -swift run celestra update --min-popularity 10 - -# Combine filters -swift run celestra update \ - --last-attempted-before 2025-01-01T00:00:00Z \ - --min-popularity 5 -``` - -Example output: -``` -🔄 Starting feed update... - Filter: last attempted before 2025-01-01T00:00:00Z - Filter: minimum popularity 5 -📋 Querying feeds... -✅ Found 3 feed(s) to update - -[1/3] 📰 Example Blog - ✅ Fetched 25 articles - ℹ️ Skipped 20 duplicate(s) - ✅ Uploaded 5 new article(s) - -[2/3] 📰 Tech News - ✅ Fetched 15 articles - ℹ️ Skipped 10 duplicate(s) - ✅ Uploaded 5 new article(s) - -[3/3] 📰 Daily Updates - ✅ Fetched 10 articles - ℹ️ No new articles to upload - -✅ Update complete! - Success: 3 - Errors: 0 -``` - -### Clear All Data - -Delete all feeds and articles from CloudKit: - -```bash -swift run celestra clear --confirm -``` - -## How It Demonstrates MistKit Features - -### 1. Query Filtering (`QueryFilter`) - -The `update` command demonstrates filtering with date and numeric comparisons: - -```swift -// In CloudKitService+Celestra.swift -var filters: [QueryFilter] = [] - -// Date comparison filter -if let cutoff = lastAttemptedBefore { - filters.append(.lessThan("lastAttempted", .date(cutoff))) -} - -// Numeric comparison filter -if let minPop = minPopularity { - filters.append(.greaterThanOrEquals("usageCount", .int64(minPop))) -} -``` - -### 2. Query Sorting (`QuerySort`) - -Results are automatically sorted by popularity (descending): - -```swift -let records = try await queryRecords( - recordType: "Feed", - filters: filters.isEmpty ? nil : filters, - sortBy: [.descending("usageCount")], // Sort by popularity - limit: limit -) -``` - -### 3. Batch Operations - -Articles are uploaded in batches using non-atomic operations for better performance: - -```swift -// Non-atomic allows partial success -return try await modifyRecords(operations: operations, atomic: false) -``` - -### 4. Duplicate Detection - -Celestra automatically detects and skips duplicate articles during feed updates: - -```swift -// In UpdateCommand.swift -// 1. Extract GUIDs from fetched articles -let guids = articles.map { $0.guid } - -// 2. Query existing articles by GUID -let existingArticles = try await service.queryArticlesByGUIDs( - guids, - feedRecordName: recordName -) - -// 3. Filter out duplicates -let existingGUIDs = Set(existingArticles.map { $0.guid }) -let newArticles = articles.filter { !existingGUIDs.contains($0.guid) } - -// 4. Only upload new articles -if !newArticles.isEmpty { - _ = try await service.createArticles(newArticles) -} -``` - -#### How Duplicate Detection Works - -1. **GUID-Based Identification**: Each article has a unique GUID (Globally Unique Identifier) from the RSS feed -2. **Pre-Upload Query**: Before uploading, Celestra queries CloudKit for existing articles with the same GUIDs -3. **Content Hash Fallback**: Articles also include a SHA256 content hash for duplicate detection when GUIDs are unreliable -4. **Efficient Filtering**: Uses Set-based filtering for O(n) performance with large article counts - -This ensures you can run `update` multiple times without creating duplicate articles in CloudKit. - -### 5. Server-to-Server Authentication - -Demonstrates CloudKit authentication without user interaction: - -```swift -let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: privateKeyPEM -) - -let service = try CloudKitService( - containerIdentifier: containerID, - tokenManager: tokenManager, - environment: environment, - database: .public -) -``` - -## Architecture - -``` -Celestra/ -├── Models/ -│ ├── Feed.swift # Feed metadata model -│ └── Article.swift # Article model -├── Services/ -│ ├── RSSFetcherService.swift # RSS parsing with SyndiKit -│ └── CloudKitService+Celestra.swift # CloudKit operations -├── Commands/ -│ ├── AddFeedCommand.swift # Add feed command -│ ├── UpdateCommand.swift # Update feeds command (demonstrates filters) -│ └── ClearCommand.swift # Clear data command -└── Celestra.swift # Main CLI entry point -``` - -## Documentation - -### CloudKit Schema Guides - -Celestra uses CloudKit's text-based schema language for database management. See these guides for working with schemas: - -- **[AI Schema Workflow Guide](./AI_SCHEMA_WORKFLOW.md)** - Comprehensive guide for AI agents and developers to understand, design, modify, and validate CloudKit schemas -- **[CloudKit Schema Setup](./CLOUDKIT_SCHEMA_SETUP.md)** - Detailed setup instructions for both automated (cktool) and manual schema configuration -- **[Schema Quick Reference](../SCHEMA_QUICK_REFERENCE.md)** - One-page cheat sheet with syntax, patterns, and common operations -- **[Task Master Schema Integration](../../.taskmaster/docs/schema-design-workflow.md)** - Integrate schema design into Task Master workflows - -### Additional Resources - -- **[Claude Code Schema Reference](../../.claude/docs/cloudkit-schema-reference.md)** - Quick reference auto-loaded in Claude Code sessions -- **[Apple's Schema Language Documentation](../../.claude/docs/sosumi-cloudkit-schema-source.md)** - Official CloudKit Schema Language reference from Apple -- **[Implementation Notes](./IMPLEMENTATION_NOTES.md)** - Design decisions and patterns used in Celestra - -## Troubleshooting - -### Authentication Errors - -- Verify your Key ID is correct -- Ensure the private key file exists and is readable -- Check that the container ID matches your CloudKit container - -### Missing Record Types - -- Make sure you created the record types in CloudKit Dashboard -- Verify you're using the correct database (public) -- Check the environment setting (development vs production) - -### Build Errors - -- Ensure Swift 5.9+ is installed: `swift --version` -- Clean and rebuild: `swift package clean && swift build` -- Update dependencies: `swift package update` - -## License - -MIT License - See main MistKit repository for details. diff --git a/Examples/Celestra/Sources/Celestra/Celestra.swift b/Examples/Celestra/Sources/Celestra/Celestra.swift deleted file mode 100644 index adccb26f..00000000 --- a/Examples/Celestra/Sources/Celestra/Celestra.swift +++ /dev/null @@ -1,66 +0,0 @@ -import ArgumentParser -import Foundation -import MistKit - -@main -struct Celestra: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "celestra", - abstract: "RSS reader that syncs to CloudKit public database", - discussion: """ - Celestra demonstrates MistKit's query filtering and sorting features by managing \ - RSS feeds in CloudKit's public database. - """, - subcommands: [ - AddFeedCommand.self, - UpdateCommand.self, - ClearCommand.self - ] - ) -} - -// MARK: - Shared Configuration - -/// Shared configuration helper for creating CloudKit service -enum CelestraConfig { - /// Create CloudKit service from environment variables - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - static func createCloudKitService() throws -> CloudKitService { - // Validate required environment variables - guard let containerID = ProcessInfo.processInfo.environment["CLOUDKIT_CONTAINER_ID"] else { - throw ValidationError("CLOUDKIT_CONTAINER_ID environment variable required") - } - - guard let keyID = ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"] else { - throw ValidationError("CLOUDKIT_KEY_ID environment variable required") - } - - guard let privateKeyPath = ProcessInfo.processInfo.environment["CLOUDKIT_PRIVATE_KEY_PATH"] - else { - throw ValidationError("CLOUDKIT_PRIVATE_KEY_PATH environment variable required") - } - - // Read private key from file - let privateKeyPEM = try String(contentsOfFile: privateKeyPath, encoding: .utf8) - - // Determine environment (development or production) - let environment: MistKit.Environment = - ProcessInfo.processInfo.environment["CLOUDKIT_ENVIRONMENT"] == "production" - ? .production - : .development - - // Create token manager for server-to-server authentication - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: privateKeyPEM - ) - - // Create and return CloudKit service - return try CloudKitService( - containerIdentifier: containerID, - tokenManager: tokenManager, - environment: environment, - database: .public - ) - } -} diff --git a/Examples/Celestra/Sources/Celestra/Commands/AddFeedCommand.swift b/Examples/Celestra/Sources/Celestra/Commands/AddFeedCommand.swift deleted file mode 100644 index cc5f3fc9..00000000 --- a/Examples/Celestra/Sources/Celestra/Commands/AddFeedCommand.swift +++ /dev/null @@ -1,55 +0,0 @@ -import ArgumentParser -import Foundation -import MistKit - -struct AddFeedCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "add-feed", - abstract: "Add a new RSS feed to CloudKit", - discussion: """ - Fetches the RSS feed to validate it and extract metadata, then creates a \ - Feed record in CloudKit's public database. - """ - ) - - @Argument(help: "RSS feed URL") - var feedURL: String - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - func run() async throws { - print("🌐 Fetching RSS feed: \(feedURL)") - - // 1. Validate URL - guard let url = URL(string: feedURL) else { - throw ValidationError("Invalid feed URL") - } - - // 2. Fetch RSS content to validate and extract title - let fetcher = RSSFetcherService() - let response = try await fetcher.fetchFeed(from: url) - - guard let feedData = response.feedData else { - throw ValidationError("Feed was not modified (unexpected)") - } - - print("✅ Found feed: \(feedData.title)") - print(" Articles: \(feedData.items.count)") - - // 3. Create CloudKit service - let service = try CelestraConfig.createCloudKitService() - - // 4. Create Feed record with initial metadata - let feed = Feed( - feedURL: feedURL, - title: feedData.title, - description: feedData.description, - lastModified: response.lastModified, - etag: response.etag, - minUpdateInterval: feedData.minUpdateInterval - ) - let record = try await service.createFeed(feed) - - print("✅ Feed added to CloudKit") - print(" Record Name: \(record.recordName)") - } -} diff --git a/Examples/Celestra/Sources/Celestra/Commands/ClearCommand.swift b/Examples/Celestra/Sources/Celestra/Commands/ClearCommand.swift deleted file mode 100644 index e5b9a814..00000000 --- a/Examples/Celestra/Sources/Celestra/Commands/ClearCommand.swift +++ /dev/null @@ -1,45 +0,0 @@ -import ArgumentParser -import Foundation -import MistKit - -struct ClearCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "clear", - abstract: "Delete all feeds and articles from CloudKit", - discussion: """ - Removes all Feed and Article records from the CloudKit public database. \ - Use with caution as this operation cannot be undone. - """ - ) - - @Flag(name: .long, help: "Skip confirmation prompt") - var confirm: Bool = false - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - func run() async throws { - // Require confirmation - if !confirm { - print("⚠️ This will DELETE ALL feeds and articles from CloudKit!") - print(" Run with --confirm to proceed") - print("") - print(" Example: celestra clear --confirm") - return - } - - print("🗑️ Clearing all data from CloudKit...") - - let service = try CelestraConfig.createCloudKitService() - - // Delete articles first (to avoid orphans) - print("📋 Deleting articles...") - try await service.deleteAllArticles() - print("✅ Articles deleted") - - // Delete feeds - print("📋 Deleting feeds...") - try await service.deleteAllFeeds() - print("✅ Feeds deleted") - - print("\n✅ All data cleared!") - } -} diff --git a/Examples/Celestra/Sources/Celestra/Commands/UpdateCommand.swift b/Examples/Celestra/Sources/Celestra/Commands/UpdateCommand.swift deleted file mode 100644 index 725637e9..00000000 --- a/Examples/Celestra/Sources/Celestra/Commands/UpdateCommand.swift +++ /dev/null @@ -1,304 +0,0 @@ -import ArgumentParser -import Foundation -import Logging -import MistKit - -struct UpdateCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "update", - abstract: "Fetch and update RSS feeds in CloudKit with web etiquette", - discussion: """ - Queries feeds from CloudKit (optionally filtered by date and popularity), \ - fetches new articles from each feed, and uploads them to CloudKit. \ - This command demonstrates MistKit's QueryFilter functionality and implements \ - web etiquette best practices including rate limiting, robots.txt checking, \ - and conditional HTTP requests. - """ - ) - - @Option(name: .long, help: "Only update feeds last attempted before this date (ISO8601 format)") - var lastAttemptedBefore: String? - - @Option(name: .long, help: "Only update feeds with minimum popularity count") - var minPopularity: Int64? - - @Option(name: .long, help: "Delay between feed fetches in seconds (default: 2.0)") - var delay: Double = 2.0 - - @Flag(name: .long, help: "Skip robots.txt checking (for testing)") - var skipRobotsCheck: Bool = false - - @Option(name: .long, help: "Skip feeds with failure count above this threshold") - var maxFailures: Int64? - - @available(macOS 13.0, *) - func run() async throws { - print("🔄 Starting feed update...") - print(" ⏱️ Rate limit: \(delay) seconds between feeds") - if skipRobotsCheck { - print(" ⚠️ Skipping robots.txt checks") - } - - // 1. Parse date filter if provided - var cutoffDate: Date? - if let dateString = lastAttemptedBefore { - let formatter = ISO8601DateFormatter() - guard let date = formatter.date(from: dateString) else { - throw ValidationError( - "Invalid date format. Use ISO8601 (e.g., 2025-01-01T00:00:00Z)" - ) - } - cutoffDate = date - print(" Filter: last attempted before \(formatter.string(from: date))") - } - - // 2. Display popularity filter if provided - if let minPop = minPopularity { - print(" Filter: minimum popularity \(minPop)") - } - - // 3. Display failure threshold if provided - if let maxFail = maxFailures { - print(" Filter: maximum failures \(maxFail)") - } - - // 4. Create services - let service = try CelestraConfig.createCloudKitService() - let fetcher = RSSFetcherService() - let robotsService = RobotsTxtService() - let rateLimiter = RateLimiter(defaultDelay: delay) - - // 5. Query feeds with filters (demonstrates QueryFilter and QuerySort) - print("📋 Querying feeds...") - var feeds = try await service.queryFeeds( - lastAttemptedBefore: cutoffDate, - minPopularity: minPopularity - ) - - // Filter by failure count if specified - if let maxFail = maxFailures { - feeds = feeds.filter { $0.failureCount <= maxFail } - } - - print("✅ Found \(feeds.count) feed(s) to update") - - // 6. Process each feed - var successCount = 0 - var errorCount = 0 - var skippedCount = 0 - var notModifiedCount = 0 - - for (index, feed) in feeds.enumerated() { - print("\n[\(index + 1)/\(feeds.count)] 📰 \(feed.title)") - - // Check if feed should be skipped based on minUpdateInterval - if let minInterval = feed.minUpdateInterval, - let lastAttempted = feed.lastAttempted { - let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempted) - if timeSinceLastAttempt < minInterval { - let remainingTime = Int((minInterval - timeSinceLastAttempt) / 60) - print(" ⏭️ Skipped (update requested in \(remainingTime) minutes)") - skippedCount += 1 - continue - } - } - - // Apply rate limiting - guard let url = URL(string: feed.feedURL) else { - print(" ❌ Invalid URL") - errorCount += 1 - continue - } - - await rateLimiter.waitIfNeeded(for: url, minimumInterval: feed.minUpdateInterval) - - // Check robots.txt unless skipped - if !skipRobotsCheck { - do { - let isAllowed = try await robotsService.isAllowed(url) - if !isAllowed { - print(" 🚫 Blocked by robots.txt") - skippedCount += 1 - continue - } - } catch { - print(" ⚠️ robots.txt check failed, proceeding anyway: \(error.localizedDescription)") - } - } - - // Track attempt - start with existing values - var totalAttempts = feed.totalAttempts + 1 - var successfulAttempts = feed.successfulAttempts - var failureCount = feed.failureCount - var lastFailureReason: String? = feed.lastFailureReason - var lastModified = feed.lastModified - var etag = feed.etag - var minUpdateInterval = feed.minUpdateInterval - - do { - // Fetch RSS with conditional request support - let response = try await fetcher.fetchFeed( - from: url, - lastModified: feed.lastModified, - etag: feed.etag - ) - - // Update HTTP metadata - lastModified = response.lastModified - etag = response.etag - - // Handle 304 Not Modified - if !response.wasModified { - print(" ✅ Not modified (saved bandwidth)") - notModifiedCount += 1 - successfulAttempts += 1 - failureCount = 0 // Reset failure count on success - lastFailureReason = nil - } else { - guard let feedData = response.feedData else { - throw CelestraError.invalidFeedData("No feed data in response") - } - - print(" ✅ Fetched \(feedData.items.count) articles") - - // Update minUpdateInterval if feed provides one - if let interval = feedData.minUpdateInterval { - minUpdateInterval = interval - } - - // Convert to PublicArticle - guard let recordName = feed.recordName else { - print(" ❌ No record name") - errorCount += 1 - continue - } - - let articles = feedData.items.map { item in - Article( - feed: recordName, - title: item.title, - link: item.link, - description: item.description, - content: item.content, - author: item.author, - pubDate: item.pubDate, - guid: item.guid, - ttlDays: 30 - ) - } - - // Duplicate detection and update logic - if !articles.isEmpty { - let guids = articles.map { $0.guid } - let existingArticles = try await service.queryArticlesByGUIDs( - guids, - feedRecordName: recordName - ) - - // Create map of existing articles by GUID for fast lookup - let existingMap = Dictionary( - uniqueKeysWithValues: existingArticles.map { ($0.guid, $0) } - ) - - // Separate articles into new vs modified - var newArticles: [Article] = [] - var modifiedArticles: [Article] = [] - - for article in articles { - if let existing = existingMap[article.guid] { - // Check if content changed - if existing.contentHash != article.contentHash { - // Content changed - need to update - modifiedArticles.append(article.withRecordName(existing.recordName!)) - } - // else: content unchanged - skip - } else { - // New article - newArticles.append(article) - } - } - - let unchangedCount = articles.count - newArticles.count - modifiedArticles.count - - // Upload new articles - if !newArticles.isEmpty { - let createResult = try await service.createArticles(newArticles) - if createResult.isFullSuccess { - print(" ✅ Created \(createResult.successCount) new article(s)") - CelestraLogger.operations.info("Created \(createResult.successCount) articles for \(feed.title)") - } else { - print(" ⚠️ Created \(createResult.successCount)/\(createResult.totalProcessed) article(s)") - CelestraLogger.errors.warning("Partial create failure: \(createResult.failureCount) failures") - } - } - - // Update modified articles - if !modifiedArticles.isEmpty { - let updateResult = try await service.updateArticles(modifiedArticles) - if updateResult.isFullSuccess { - print(" 🔄 Updated \(updateResult.successCount) modified article(s)") - CelestraLogger.operations.info("Updated \(updateResult.successCount) articles for \(feed.title)") - } else { - print(" ⚠️ Updated \(updateResult.successCount)/\(updateResult.totalProcessed) article(s)") - CelestraLogger.errors.warning("Partial update failure: \(updateResult.failureCount) failures") - } - } - - // Report unchanged articles - if unchangedCount > 0 { - print(" ℹ️ Skipped \(unchangedCount) unchanged article(s)") - } - - // Report if nothing to do - if newArticles.isEmpty && modifiedArticles.isEmpty { - print(" ℹ️ No new or modified articles") - } - } - - successfulAttempts += 1 - failureCount = 0 // Reset failure count on success - lastFailureReason = nil - } - - successCount += 1 - - } catch { - print(" ❌ Error: \(error.localizedDescription)") - errorCount += 1 - failureCount += 1 - lastFailureReason = error.localizedDescription - } - - // Update feed with new metadata - let updatedFeed = Feed( - recordName: feed.recordName, - recordChangeTag: feed.recordChangeTag, - feedURL: feed.feedURL, - title: feed.title, - description: feed.description, - totalAttempts: totalAttempts, - successfulAttempts: successfulAttempts, - usageCount: feed.usageCount, - lastAttempted: Date(), - isActive: feed.isActive, - lastModified: lastModified, - etag: etag, - failureCount: failureCount, - lastFailureReason: lastFailureReason, - minUpdateInterval: minUpdateInterval - ) - - // Update feed record in CloudKit - if let recordName = feed.recordName { - _ = try await service.updateFeed(recordName: recordName, feed: updatedFeed) - } - } - - // 7. Print summary - print("\n✅ Update complete!") - print(" Success: \(successCount)") - print(" Not Modified: \(notModifiedCount)") - print(" Skipped: \(skippedCount)") - print(" Errors: \(errorCount)") - } -} diff --git a/Examples/Celestra/Sources/Celestra/Models/Article.swift b/Examples/Celestra/Sources/Celestra/Models/Article.swift deleted file mode 100644 index 4fcbe81b..00000000 --- a/Examples/Celestra/Sources/Celestra/Models/Article.swift +++ /dev/null @@ -1,164 +0,0 @@ -import Foundation -import MistKit -import CryptoKit - -/// Represents an RSS article stored in CloudKit's public database -struct Article { - let recordName: String? - let feed: String // Feed record name (stored as REFERENCE in CloudKit) - let title: String - let link: String - let description: String? - let content: String? - let author: String? - let pubDate: Date? - let guid: String - let fetchedAt: Date - let expiresAt: Date - - /// Computed content hash for duplicate detection fallback - var contentHash: String { - let content = "\(title)|\(link)|\(guid)" - let data = Data(content.utf8) - let hash = SHA256.hash(data: data) - return hash.compactMap { String(format: "%02x", $0) }.joined() - } - - /// Convert to CloudKit record fields dictionary - func toFieldsDict() -> [String: FieldValue] { - var fields: [String: FieldValue] = [ - "feed": .reference(FieldValue.Reference(recordName: feed)), - "title": .string(title), - "link": .string(link), - "guid": .string(guid), - "contentHash": .string(contentHash), - "fetchedAt": .date(fetchedAt), - "expiresAt": .date(expiresAt) - ] - if let description = description { - fields["description"] = .string(description) - } - if let content = content { - fields["content"] = .string(content) - } - if let author = author { - fields["author"] = .string(author) - } - if let pubDate = pubDate { - fields["pubDate"] = .date(pubDate) - } - return fields - } - - /// Create from CloudKit RecordInfo - init(from record: RecordInfo) { - self.recordName = record.recordName - - // Extract feed reference - if case .reference(let ref) = record.fields["feed"] { - self.feed = ref.recordName - } else { - self.feed = "" - } - - if case .string(let value) = record.fields["title"] { - self.title = value - } else { - self.title = "" - } - - if case .string(let value) = record.fields["link"] { - self.link = value - } else { - self.link = "" - } - - if case .string(let value) = record.fields["guid"] { - self.guid = value - } else { - self.guid = "" - } - - // Extract optional string values - if case .string(let value) = record.fields["description"] { - self.description = value - } else { - self.description = nil - } - - if case .string(let value) = record.fields["content"] { - self.content = value - } else { - self.content = nil - } - - if case .string(let value) = record.fields["author"] { - self.author = value - } else { - self.author = nil - } - - // Extract date values - if case .date(let value) = record.fields["pubDate"] { - self.pubDate = value - } else { - self.pubDate = nil - } - - if case .date(let value) = record.fields["fetchedAt"] { - self.fetchedAt = value - } else { - self.fetchedAt = Date() - } - - if case .date(let value) = record.fields["expiresAt"] { - self.expiresAt = value - } else { - self.expiresAt = Date() - } - } - - /// Create new article record - init( - recordName: String? = nil, - feed: String, - title: String, - link: String, - description: String? = nil, - content: String? = nil, - author: String? = nil, - pubDate: Date? = nil, - guid: String, - ttlDays: Int = 30 - ) { - self.recordName = recordName - self.feed = feed - self.title = title - self.link = link - self.description = description - self.content = content - self.author = author - self.pubDate = pubDate - self.guid = guid - self.fetchedAt = Date() - self.expiresAt = Date().addingTimeInterval(TimeInterval(ttlDays * 24 * 60 * 60)) - } - - /// Create a copy of this article with a specific recordName - /// - Parameter recordName: The CloudKit record name to set - /// - Returns: New Article instance with the recordName set - func withRecordName(_ recordName: String) -> Article { - Article( - recordName: recordName, - feed: self.feed, - title: self.title, - link: self.link, - description: self.description, - content: self.content, - author: self.author, - pubDate: self.pubDate, - guid: self.guid, - ttlDays: 30 // Use default TTL since we can't calculate from existing dates - ) - } -} diff --git a/Examples/Celestra/Sources/Celestra/Models/BatchOperationResult.swift b/Examples/Celestra/Sources/Celestra/Models/BatchOperationResult.swift deleted file mode 100644 index 0899bb0e..00000000 --- a/Examples/Celestra/Sources/Celestra/Models/BatchOperationResult.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -import MistKit - -/// Result of a batch CloudKit operation -struct BatchOperationResult { - /// Successfully created/updated records - var successfulRecords: [RecordInfo] = [] - - /// Records that failed to process - var failedRecords: [(article: Article, error: Error)] = [] - - /// Total number of records processed (success + failure) - var totalProcessed: Int { - successfulRecords.count + failedRecords.count - } - - /// Number of successful operations - var successCount: Int { - successfulRecords.count - } - - /// Number of failed operations - var failureCount: Int { - failedRecords.count - } - - /// Success rate as a percentage (0-100) - var successRate: Double { - guard totalProcessed > 0 else { return 0 } - return Double(successCount) / Double(totalProcessed) * 100 - } - - /// Whether all operations succeeded - var isFullSuccess: Bool { - failureCount == 0 && successCount > 0 - } - - /// Whether all operations failed - var isFullFailure: Bool { - successCount == 0 && failureCount > 0 - } - - // MARK: - Mutation - - /// Append results from another batch operation - mutating func append(_ other: BatchOperationResult) { - successfulRecords.append(contentsOf: other.successfulRecords) - failedRecords.append(contentsOf: other.failedRecords) - } - - /// Append successful records - mutating func appendSuccesses(_ records: [RecordInfo]) { - successfulRecords.append(contentsOf: records) - } - - /// Append a failure - mutating func appendFailure(article: Article, error: Error) { - failedRecords.append((article, error)) - } -} diff --git a/Examples/Celestra/Sources/Celestra/Models/Feed.swift b/Examples/Celestra/Sources/Celestra/Models/Feed.swift deleted file mode 100644 index 3f3eb0dd..00000000 --- a/Examples/Celestra/Sources/Celestra/Models/Feed.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Foundation -import MistKit - -/// Represents an RSS feed stored in CloudKit's public database -struct Feed { - let recordName: String? // nil for new records - let recordChangeTag: String? // CloudKit change tag for optimistic locking - let feedURL: String - let title: String - let description: String? - let totalAttempts: Int64 - let successfulAttempts: Int64 - let usageCount: Int64 - let lastAttempted: Date? - let isActive: Bool - - // Web etiquette fields - let lastModified: String? // HTTP Last-Modified header for conditional requests - let etag: String? // ETag header for conditional requests - let failureCount: Int64 // Consecutive failure count - let lastFailureReason: String? // Last error message - let minUpdateInterval: TimeInterval? // Minimum seconds between updates (from RSS or ) - - /// Convert to CloudKit record fields dictionary - func toFieldsDict() -> [String: FieldValue] { - var fields: [String: FieldValue] = [ - "feedURL": .string(feedURL), - "title": .string(title), - "totalAttempts": .int64(Int(totalAttempts)), - "successfulAttempts": .int64(Int(successfulAttempts)), - "usageCount": .int64(Int(usageCount)), - "isActive": .int64(isActive ? 1 : 0), - "failureCount": .int64(Int(failureCount)) - ] - if let description = description { - fields["description"] = .string(description) - } - if let lastAttempted = lastAttempted { - fields["lastAttempted"] = .date(lastAttempted) - } - if let lastModified = lastModified { - fields["lastModified"] = .string(lastModified) - } - if let etag = etag { - fields["etag"] = .string(etag) - } - if let lastFailureReason = lastFailureReason { - fields["lastFailureReason"] = .string(lastFailureReason) - } - if let minUpdateInterval = minUpdateInterval { - fields["minUpdateInterval"] = .double(minUpdateInterval) - } - return fields - } - - /// Create from CloudKit RecordInfo - init(from record: RecordInfo) { - self.recordName = record.recordName - self.recordChangeTag = record.recordChangeTag - - // Extract string values - if case .string(let value) = record.fields["feedURL"] { - self.feedURL = value - } else { - self.feedURL = "" - } - - if case .string(let value) = record.fields["title"] { - self.title = value - } else { - self.title = "" - } - - if case .string(let value) = record.fields["description"] { - self.description = value - } else { - self.description = nil - } - - // Extract Int64 values - if case .int64(let value) = record.fields["totalAttempts"] { - self.totalAttempts = Int64(value) - } else { - self.totalAttempts = 0 - } - - if case .int64(let value) = record.fields["successfulAttempts"] { - self.successfulAttempts = Int64(value) - } else { - self.successfulAttempts = 0 - } - - if case .int64(let value) = record.fields["usageCount"] { - self.usageCount = Int64(value) - } else { - self.usageCount = 0 - } - - // Extract boolean as Int64 - if case .int64(let value) = record.fields["isActive"] { - self.isActive = value != 0 - } else { - self.isActive = true // Default to active - } - - // Extract date value - if case .date(let value) = record.fields["lastAttempted"] { - self.lastAttempted = value - } else { - self.lastAttempted = nil - } - - // Extract web etiquette fields - if case .string(let value) = record.fields["lastModified"] { - self.lastModified = value - } else { - self.lastModified = nil - } - - if case .string(let value) = record.fields["etag"] { - self.etag = value - } else { - self.etag = nil - } - - if case .int64(let value) = record.fields["failureCount"] { - self.failureCount = Int64(value) - } else { - self.failureCount = 0 - } - - if case .string(let value) = record.fields["lastFailureReason"] { - self.lastFailureReason = value - } else { - self.lastFailureReason = nil - } - - if case .double(let value) = record.fields["minUpdateInterval"] { - self.minUpdateInterval = value - } else { - self.minUpdateInterval = nil - } - } - - /// Create new feed record - init( - recordName: String? = nil, - recordChangeTag: String? = nil, - feedURL: String, - title: String, - description: String? = nil, - totalAttempts: Int64 = 0, - successfulAttempts: Int64 = 0, - usageCount: Int64 = 0, - lastAttempted: Date? = nil, - isActive: Bool = true, - lastModified: String? = nil, - etag: String? = nil, - failureCount: Int64 = 0, - lastFailureReason: String? = nil, - minUpdateInterval: TimeInterval? = nil - ) { - self.recordName = recordName - self.recordChangeTag = recordChangeTag - self.feedURL = feedURL - self.title = title - self.description = description - self.totalAttempts = totalAttempts - self.successfulAttempts = successfulAttempts - self.usageCount = usageCount - self.lastAttempted = lastAttempted - self.isActive = isActive - self.lastModified = lastModified - self.etag = etag - self.failureCount = failureCount - self.lastFailureReason = lastFailureReason - self.minUpdateInterval = minUpdateInterval - } -} diff --git a/Examples/Celestra/Sources/Celestra/Services/CelestraLogger.swift b/Examples/Celestra/Sources/Celestra/Services/CelestraLogger.swift deleted file mode 100644 index 13878f43..00000000 --- a/Examples/Celestra/Sources/Celestra/Services/CelestraLogger.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Logging - -/// Centralized logging infrastructure for Celestra using swift-log -enum CelestraLogger { - /// Logger for CloudKit operations - static let cloudkit = Logger(label: "com.brightdigit.Celestra.cloudkit") - - /// Logger for RSS feed operations - static let rss = Logger(label: "com.brightdigit.Celestra.rss") - - /// Logger for batch and async operations - static let operations = Logger(label: "com.brightdigit.Celestra.operations") - - /// Logger for error handling and diagnostics - static let errors = Logger(label: "com.brightdigit.Celestra.errors") -} diff --git a/Examples/Celestra/Sources/Celestra/Services/CloudKitService+Celestra.swift b/Examples/Celestra/Sources/Celestra/Services/CloudKitService+Celestra.swift deleted file mode 100644 index 34f397d5..00000000 --- a/Examples/Celestra/Sources/Celestra/Services/CloudKitService+Celestra.swift +++ /dev/null @@ -1,307 +0,0 @@ -import Foundation -import Logging -import MistKit - -/// CloudKit service extensions for Celestra operations -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService { - // MARK: - Feed Operations - - /// Create a new Feed record - func createFeed(_ feed: Feed) async throws -> RecordInfo { - CelestraLogger.cloudkit.info("📝 Creating feed: \(feed.feedURL)") - - let operation = RecordOperation.create( - recordType: "Feed", - recordName: UUID().uuidString, - fields: feed.toFieldsDict() - ) - let results = try await self.modifyRecords([operation]) - guard let record = results.first else { - throw CloudKitError.invalidResponse - } - return record - } - - /// Update an existing Feed record - func updateFeed(recordName: String, feed: Feed) async throws -> RecordInfo { - CelestraLogger.cloudkit.info("🔄 Updating feed: \(feed.feedURL)") - - let operation = RecordOperation.update( - recordType: "Feed", - recordName: recordName, - fields: feed.toFieldsDict(), - recordChangeTag: feed.recordChangeTag - ) - let results = try await self.modifyRecords([operation]) - guard let record = results.first else { - throw CloudKitError.invalidResponse - } - return record - } - - /// Query feeds with optional filters (demonstrates QueryFilter and QuerySort) - func queryFeeds( - lastAttemptedBefore: Date? = nil, - minPopularity: Int64? = nil, - limit: Int = 100 - ) async throws -> [Feed] { - var filters: [QueryFilter] = [] - - // Filter by last attempted date if provided - if let cutoff = lastAttemptedBefore { - filters.append(.lessThan("lastAttempted", .date(cutoff))) - } - - // Filter by minimum popularity if provided - if let minPop = minPopularity { - filters.append(.greaterThanOrEquals("usageCount", .int64(Int(minPop)))) - } - - // Query with filters and sort by feedURL (always queryable+sortable) - let records = try await queryRecords( - recordType: "Feed", - filters: filters.isEmpty ? nil : filters, - sortBy: [.ascending("feedURL")], // Use feedURL since usageCount might have issues - limit: limit - ) - - return records.map { Feed(from: $0) } - } - - // MARK: - Article Operations - - /// Query existing articles by GUIDs for duplicate detection - /// - Parameters: - /// - guids: Array of article GUIDs to check - /// - feedRecordName: Optional feed record name filter to scope the query - /// - Returns: Array of existing Article records matching the GUIDs - func queryArticlesByGUIDs( - _ guids: [String], - feedRecordName: String? = nil - ) async throws -> [Article] { - guard !guids.isEmpty else { - return [] - } - - var filters: [QueryFilter] = [] - - // Add feed filter if provided - if let feedName = feedRecordName { - filters.append(.equals("feed", .reference(FieldValue.Reference(recordName: feedName)))) - } - - // For small number of GUIDs, we can query directly - // For larger sets, might need multiple queries or alternative strategy - if guids.count <= 10 { - // Create OR filter for GUIDs - let guidFilters = guids.map { guid in - QueryFilter.equals("guid", .string(guid)) - } - - // Combine with feed filter if present - if !filters.isEmpty { - // When we have both feed and GUID filters, we need to do multiple queries - // or fetch all for feed and filter in memory - filters.append(.equals("guid", .string(guids[0]))) - - let records = try await queryRecords( - recordType: "Article", - filters: filters, - limit: 200, - desiredKeys: ["guid", "contentHash", "___recordID"] - ) - - // For now, fetch all articles for this feed and filter in memory - let allFeedArticles = records.map { Article(from: $0) } - let guidSet = Set(guids) - return allFeedArticles.filter { guidSet.contains($0.guid) } - } else { - // Just GUID filter - need to query each individually or use contentHash - // For simplicity, query by feedRecordName first then filter - let records = try await queryRecords( - recordType: "Article", - limit: 200, - desiredKeys: ["guid", "contentHash", "___recordID"] - ) - - let articles = records.map { Article(from: $0) } - let guidSet = Set(guids) - return articles.filter { guidSet.contains($0.guid) } - } - } else { - // For large GUID sets, fetch all articles for the feed and filter in memory - if let feedName = feedRecordName { - filters = [.equals("feed", .reference(FieldValue.Reference(recordName: feedName)))] - } - - let records = try await queryRecords( - recordType: "Article", - filters: filters.isEmpty ? nil : filters, - limit: 200, - desiredKeys: ["guid", "contentHash", "___recordID"] - ) - - let articles = records.map { Article(from: $0) } - let guidSet = Set(guids) - return articles.filter { guidSet.contains($0.guid) } - } - } - - /// Create multiple Article records in batches with retry logic - /// - Parameter articles: Articles to create - /// - Returns: Batch operation result with success/failure tracking - func createArticles(_ articles: [Article]) async throws -> BatchOperationResult { - guard !articles.isEmpty else { - return BatchOperationResult() - } - - CelestraLogger.cloudkit.info("📦 Creating \(articles.count) article(s)...") - - // Chunk articles into batches of 10 to keep payload size manageable with full content - let batches = articles.chunked(into: 10) - var result = BatchOperationResult() - - for (index, batch) in batches.enumerated() { - CelestraLogger.operations.info(" Batch \(index + 1)/\(batches.count): \(batch.count) article(s)") - - do { - let operations = batch.map { article in - RecordOperation.create( - recordType: "Article", - recordName: UUID().uuidString, - fields: article.toFieldsDict() - ) - } - - let recordInfos = try await self.modifyRecords(operations) - - result.appendSuccesses(recordInfos) - CelestraLogger.cloudkit.info(" ✅ Batch \(index + 1) complete: \(recordInfos.count) created") - } catch { - CelestraLogger.errors.error(" ❌ Batch \(index + 1) failed: \(error.localizedDescription)") - - // Track individual failures - for article in batch { - result.appendFailure(article: article, error: error) - } - } - } - - CelestraLogger.cloudkit.info( - "📊 Batch operation complete: \(result.successCount)/\(result.totalProcessed) succeeded (\(String(format: "%.1f", result.successRate))%)" - ) - - return result - } - - /// Update multiple Article records in batches with retry logic - /// - Parameter articles: Articles to update (must have recordName set) - /// - Returns: Batch operation result with success/failure tracking - func updateArticles(_ articles: [Article]) async throws -> BatchOperationResult { - guard !articles.isEmpty else { - return BatchOperationResult() - } - - CelestraLogger.cloudkit.info("🔄 Updating \(articles.count) article(s)...") - - // Filter out articles without recordName - let validArticles = articles.filter { $0.recordName != nil } - if validArticles.count != articles.count { - CelestraLogger.errors.warning( - "⚠️ Skipping \(articles.count - validArticles.count) article(s) without recordName" - ) - } - - guard !validArticles.isEmpty else { - return BatchOperationResult() - } - - // Chunk articles into batches of 10 to keep payload size manageable with full content - let batches = validArticles.chunked(into: 10) - var result = BatchOperationResult() - - for (index, batch) in batches.enumerated() { - CelestraLogger.operations.info(" Batch \(index + 1)/\(batches.count): \(batch.count) article(s)") - - do { - let operations = batch.compactMap { article -> RecordOperation? in - guard let recordName = article.recordName else { return nil } - - return RecordOperation.update( - recordType: "Article", - recordName: recordName, - fields: article.toFieldsDict(), - recordChangeTag: nil - ) - } - - let recordInfos = try await self.modifyRecords(operations) - - result.appendSuccesses(recordInfos) - CelestraLogger.cloudkit.info(" ✅ Batch \(index + 1) complete: \(recordInfos.count) updated") - } catch { - CelestraLogger.errors.error(" ❌ Batch \(index + 1) failed: \(error.localizedDescription)") - - // Track individual failures - for article in batch { - result.appendFailure(article: article, error: error) - } - } - } - - CelestraLogger.cloudkit.info( - "📊 Update complete: \(result.successCount)/\(result.totalProcessed) succeeded (\(String(format: "%.1f", result.successRate))%)" - ) - - return result - } - - // MARK: - Cleanup Operations - - /// Delete all Feed records - func deleteAllFeeds() async throws { - let feeds = try await queryRecords( - recordType: "Feed", - limit: 200, - desiredKeys: ["___recordID"] - ) - - guard !feeds.isEmpty else { - return - } - - let operations = feeds.map { record in - RecordOperation.delete( - recordType: "Feed", - recordName: record.recordName, - recordChangeTag: record.recordChangeTag - ) - } - - _ = try await modifyRecords(operations) - } - - /// Delete all Article records - func deleteAllArticles() async throws { - let articles = try await queryRecords( - recordType: "Article", - limit: 200, - desiredKeys: ["___recordID"] - ) - - guard !articles.isEmpty else { - return - } - - let operations = articles.map { record in - RecordOperation.delete( - recordType: "Article", - recordName: record.recordName, - recordChangeTag: record.recordChangeTag - ) - } - - _ = try await modifyRecords(operations) - } -} diff --git a/Examples/Celestra/Sources/Celestra/Services/RSSFetcherService.swift b/Examples/Celestra/Sources/Celestra/Services/RSSFetcherService.swift deleted file mode 100644 index d491a655..00000000 --- a/Examples/Celestra/Sources/Celestra/Services/RSSFetcherService.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Foundation -import Logging -import SyndiKit - -/// Service for fetching and parsing RSS feeds using SyndiKit with web etiquette -@available(macOS 13.0, *) -struct RSSFetcherService { - private let urlSession: URLSession - private let userAgent: String - - struct FeedData { - let title: String - let description: String? - let items: [FeedItem] - let minUpdateInterval: TimeInterval? // Parsed from or - } - - struct FeedItem { - let title: String - let link: String - let description: String? - let content: String? - let author: String? - let pubDate: Date? - let guid: String - } - - struct FetchResponse { - let feedData: FeedData? // nil if 304 Not Modified - let lastModified: String? - let etag: String? - let wasModified: Bool - } - - init(userAgent: String = "Celestra/1.0 (MistKit RSS Reader; +https://github.com/brightdigit/MistKit)") { - self.userAgent = userAgent - - // Create custom URLSession with proper configuration - let configuration = URLSessionConfiguration.default - configuration.httpAdditionalHeaders = [ - "User-Agent": userAgent, - "Accept": "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7" - ] - - self.urlSession = URLSession(configuration: configuration) - } - - /// Fetch and parse RSS feed from URL with conditional request support - /// - Parameters: - /// - url: Feed URL to fetch - /// - lastModified: Optional Last-Modified header from previous fetch - /// - etag: Optional ETag header from previous fetch - /// - Returns: Fetch response with feed data and HTTP metadata - func fetchFeed( - from url: URL, - lastModified: String? = nil, - etag: String? = nil - ) async throws -> FetchResponse { - CelestraLogger.rss.info("📡 Fetching RSS feed from \(url.absoluteString)") - - // Build request with conditional headers - var request = URLRequest(url: url) - if let lastModified = lastModified { - request.setValue(lastModified, forHTTPHeaderField: "If-Modified-Since") - } - if let etag = etag { - request.setValue(etag, forHTTPHeaderField: "If-None-Match") - } - - do { - // 1. Fetch RSS XML from URL - let (data, response) = try await urlSession.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw CelestraError.invalidFeedData("Non-HTTP response") - } - - // Extract response headers - let responseLastModified = httpResponse.value(forHTTPHeaderField: "Last-Modified") - let responseEtag = httpResponse.value(forHTTPHeaderField: "ETag") - - // Handle 304 Not Modified - if httpResponse.statusCode == 304 { - CelestraLogger.rss.info("✅ Feed not modified (304)") - return FetchResponse( - feedData: nil, - lastModified: responseLastModified ?? lastModified, - etag: responseEtag ?? etag, - wasModified: false - ) - } - - // Check for error status codes - guard (200...299).contains(httpResponse.statusCode) else { - throw CelestraError.rssFetchFailed(url, underlying: URLError(.badServerResponse)) - } - - // 2. Parse feed using SyndiKit - let decoder = SynDecoder() - let feed = try decoder.decode(data) - - // 3. Parse RSS metadata for update intervals - let minUpdateInterval = parseUpdateInterval(from: feed) - - // 4. Convert Feedable to our FeedData structure - let items = feed.children.compactMap { entry -> FeedItem? in - // Get link from url property or use id's description as fallback - let link: String - if let url = entry.url { - link = url.absoluteString - } else if case .url(let url) = entry.id { - link = url.absoluteString - } else { - // Use id's string representation as fallback - link = entry.id.description - } - - // Skip if link is empty - guard !link.isEmpty else { - return nil - } - - return FeedItem( - title: entry.title, - link: link, - description: entry.summary, - content: entry.contentHtml, - author: entry.authors.first?.name, - pubDate: entry.published, - guid: entry.id.description // Use id's description as guid - ) - } - - let feedData = FeedData( - title: feed.title, - description: feed.summary, - items: items, - minUpdateInterval: minUpdateInterval - ) - - CelestraLogger.rss.info("✅ Successfully fetched feed: \(feed.title) (\(items.count) items)") - if let interval = minUpdateInterval { - CelestraLogger.rss.info(" 📅 Feed requests updates every \(Int(interval / 60)) minutes") - } - - return FetchResponse( - feedData: feedData, - lastModified: responseLastModified, - etag: responseEtag, - wasModified: true - ) - - } catch let error as DecodingError { - CelestraLogger.errors.error("❌ Failed to parse feed: \(error.localizedDescription)") - throw CelestraError.invalidFeedData(error.localizedDescription) - } catch { - CelestraLogger.errors.error("❌ Failed to fetch feed: \(error.localizedDescription)") - throw CelestraError.rssFetchFailed(url, underlying: error) - } - } - - /// Parse minimum update interval from RSS feed metadata - /// - Parameter feed: Parsed feed from SyndiKit - /// - Returns: Minimum update interval in seconds, or nil if not specified - private func parseUpdateInterval(from feed: Feedable) -> TimeInterval? { - // Try to access raw XML for custom elements - // SyndiKit may not expose all RSS extensions directly - - // For now, we'll use a simple heuristic: - // - If feed has tag (in minutes), use that - // - If feed has and (Syndication module), use that - // - Otherwise, default to nil (no preference) - - // Note: SyndiKit's Feedable protocol doesn't expose these directly, - // so we'd need to access the raw XML or extend SyndiKit - // For this implementation, we'll parse common values if available - - // Default: no specific interval (1 hour minimum is reasonable) - // This could be enhanced by parsing the raw XML data - return nil // TODO: Implement RSS and parsing if needed - } -} diff --git a/Examples/Celestra/Sources/Celestra/Services/RateLimiter.swift b/Examples/Celestra/Sources/Celestra/Services/RateLimiter.swift deleted file mode 100644 index a9fa8738..00000000 --- a/Examples/Celestra/Sources/Celestra/Services/RateLimiter.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -/// Actor-based rate limiter for RSS feed fetching -actor RateLimiter { - private var lastFetchTimes: [String: Date] = [:] - private let defaultDelay: TimeInterval - private let perDomainDelay: TimeInterval - - /// Initialize rate limiter with configurable delays - /// - Parameters: - /// - defaultDelay: Default delay between any two feed fetches (seconds) - /// - perDomainDelay: Additional delay when fetching from the same domain (seconds) - init(defaultDelay: TimeInterval = 2.0, perDomainDelay: TimeInterval = 5.0) { - self.defaultDelay = defaultDelay - self.perDomainDelay = perDomainDelay - } - - /// Wait if necessary before fetching a URL - /// - Parameters: - /// - url: The URL to fetch - /// - minimumInterval: Optional minimum interval to respect (e.g., from RSS ) - func waitIfNeeded(for url: URL, minimumInterval: TimeInterval? = nil) async { - guard let host = url.host else { - return - } - - let now = Date() - let key = host - - // Determine the required delay - var requiredDelay = defaultDelay - - // Use per-domain delay if we've fetched from this domain before - if let lastFetch = lastFetchTimes[key] { - requiredDelay = max(requiredDelay, perDomainDelay) - - // If feed specifies minimum interval, respect it - if let minInterval = minimumInterval { - requiredDelay = max(requiredDelay, minInterval) - } - - let timeSinceLastFetch = now.timeIntervalSince(lastFetch) - let remainingDelay = requiredDelay - timeSinceLastFetch - - if remainingDelay > 0 { - let nanoseconds = UInt64(remainingDelay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - } - } - - // Record this fetch time - lastFetchTimes[key] = Date() - } - - /// Wait with a global delay (between any two fetches, regardless of domain) - func waitGlobal() async { - // Get the most recent fetch time across all domains - if let mostRecent = lastFetchTimes.values.max() { - let timeSinceLastFetch = Date().timeIntervalSince(mostRecent) - let remainingDelay = defaultDelay - timeSinceLastFetch - - if remainingDelay > 0 { - let nanoseconds = UInt64(remainingDelay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - } - } - } - - /// Clear all rate limiting history - func reset() { - lastFetchTimes.removeAll() - } - - /// Clear rate limiting history for a specific domain - func reset(for host: String) { - lastFetchTimes.removeValue(forKey: host) - } -} diff --git a/Examples/Celestra/Sources/Celestra/Services/RobotsTxtService.swift b/Examples/Celestra/Sources/Celestra/Services/RobotsTxtService.swift deleted file mode 100644 index 5f063af1..00000000 --- a/Examples/Celestra/Sources/Celestra/Services/RobotsTxtService.swift +++ /dev/null @@ -1,174 +0,0 @@ -import Foundation -import Logging - -/// Service for fetching and parsing robots.txt files -actor RobotsTxtService { - private var cache: [String: RobotsRules] = [:] - private let userAgent: String - - /// Represents parsed robots.txt rules for a domain - struct RobotsRules { - let disallowedPaths: [String] - let crawlDelay: TimeInterval? - let fetchedAt: Date - - /// Check if a given path is allowed - func isAllowed(_ path: String) -> Bool { - // If no disallow rules, everything is allowed - guard !disallowedPaths.isEmpty else { - return true - } - - // Check if path matches any disallow rule - for disallowedPath in disallowedPaths { - if path.hasPrefix(disallowedPath) { - return false - } - } - - return true - } - } - - init(userAgent: String = "Celestra") { - self.userAgent = userAgent - } - - /// Check if a URL is allowed by robots.txt - /// - Parameter url: The URL to check - /// - Returns: True if allowed, false if disallowed - func isAllowed(_ url: URL) async throws -> Bool { - guard let host = url.host else { - // If no host, assume allowed - return true - } - - // Get or fetch robots.txt for this domain - let rules = try await getRules(for: host) - - // Check if the path is allowed - return rules.isAllowed(url.path) - } - - /// Get crawl delay for a domain from robots.txt - /// - Parameter url: The URL to check - /// - Returns: Crawl delay in seconds, or nil if not specified - func getCrawlDelay(for url: URL) async throws -> TimeInterval? { - guard let host = url.host else { - return nil - } - - let rules = try await getRules(for: host) - return rules.crawlDelay - } - - /// Get or fetch robots.txt rules for a domain - private func getRules(for host: String) async throws -> RobotsRules { - // Check cache first (cache for 24 hours) - if let cached = cache[host], - Date().timeIntervalSince(cached.fetchedAt) < 86400 { - return cached - } - - // Fetch and parse robots.txt - let rules = try await fetchAndParseRobotsTxt(for: host) - cache[host] = rules - return rules - } - - /// Fetch and parse robots.txt for a domain - private func fetchAndParseRobotsTxt(for host: String) async throws -> RobotsRules { - let robotsURL = URL(string: "https://\(host)/robots.txt")! - - do { - let (data, response) = try await URLSession.shared.data(from: robotsURL) - - guard let httpResponse = response as? HTTPURLResponse else { - // Default to allow if we can't get a response - return RobotsRules(disallowedPaths: [], crawlDelay: nil, fetchedAt: Date()) - } - - // If robots.txt doesn't exist, allow everything - guard httpResponse.statusCode == 200 else { - return RobotsRules(disallowedPaths: [], crawlDelay: nil, fetchedAt: Date()) - } - - guard let content = String(data: data, encoding: .utf8) else { - // Can't parse, default to allow - return RobotsRules(disallowedPaths: [], crawlDelay: nil, fetchedAt: Date()) - } - - return parseRobotsTxt(content) - - } catch { - // Network error - default to allow (fail open) - CelestraLogger.rss.warning("⚠️ Failed to fetch robots.txt for \(host): \(error.localizedDescription)") - return RobotsRules(disallowedPaths: [], crawlDelay: nil, fetchedAt: Date()) - } - } - - /// Parse robots.txt content - private func parseRobotsTxt(_ content: String) -> RobotsRules { - var disallowedPaths: [String] = [] - var crawlDelay: TimeInterval? - var isRelevantUserAgent = false - - let lines = content.components(separatedBy: .newlines) - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - // Skip empty lines and comments - guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { - continue - } - - // Split on first colon - let parts = trimmed.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: true) - guard parts.count == 2 else { - continue - } - - let directive = parts[0].trimmingCharacters(in: .whitespaces).lowercased() - let value = parts[1].trimmingCharacters(in: .whitespaces) - - switch directive { - case "user-agent": - // Check if this section applies to us - let agentPattern = value.lowercased() - isRelevantUserAgent = agentPattern == "*" || - agentPattern == userAgent.lowercased() || - agentPattern.contains(userAgent.lowercased()) - - case "disallow": - if isRelevantUserAgent && !value.isEmpty { - disallowedPaths.append(value) - } - - case "crawl-delay": - if isRelevantUserAgent, let delay = Double(value) { - crawlDelay = delay - } - - default: - break - } - } - - return RobotsRules( - disallowedPaths: disallowedPaths, - crawlDelay: crawlDelay, - fetchedAt: Date() - ) - } - - /// Clear the robots.txt cache - func clearCache() { - cache.removeAll() - } - - /// Clear cache for a specific domain - func clearCache(for host: String) { - cache.removeValue(forKey: host) - } -} diff --git a/Examples/Celestra/schema.ckdb b/Examples/Celestra/schema.ckdb deleted file mode 100644 index 3060cf02..00000000 --- a/Examples/Celestra/schema.ckdb +++ /dev/null @@ -1,36 +0,0 @@ -DEFINE SCHEMA - -RECORD TYPE Feed ( - "___recordID" REFERENCE QUERYABLE, - "feedURL" STRING QUERYABLE SORTABLE, - "title" STRING SEARCHABLE, - "description" STRING, - "totalAttempts" INT64, - "successfulAttempts" INT64, - "usageCount" INT64 QUERYABLE SORTABLE, - "lastAttempted" TIMESTAMP QUERYABLE SORTABLE, - "isActive" INT64 QUERYABLE, - - GRANT READ, CREATE, WRITE TO "_creator", - GRANT READ, CREATE, WRITE TO "_icloud", - GRANT READ TO "_world" -); - -RECORD TYPE Article ( - "___recordID" REFERENCE QUERYABLE, - "feed" REFERENCE QUERYABLE, - "title" STRING SEARCHABLE, - "link" STRING, - "description" STRING, - "content" STRING SEARCHABLE, - "author" STRING QUERYABLE, - "pubDate" TIMESTAMP QUERYABLE SORTABLE, - "guid" STRING QUERYABLE SORTABLE, - "contentHash" STRING QUERYABLE, - "fetchedAt" TIMESTAMP QUERYABLE SORTABLE, - "expiresAt" TIMESTAMP QUERYABLE SORTABLE, - - GRANT READ, CREATE, WRITE TO "_creator", - GRANT READ, CREATE, WRITE TO "_icloud", - GRANT READ TO "_world" -); diff --git a/Examples/Celestra/AI_SCHEMA_WORKFLOW.md b/Examples/CelestraCloud/.claude/AI_SCHEMA_WORKFLOW.md similarity index 100% rename from Examples/Celestra/AI_SCHEMA_WORKFLOW.md rename to Examples/CelestraCloud/.claude/AI_SCHEMA_WORKFLOW.md diff --git a/Examples/Celestra/CLOUDKIT_SCHEMA_SETUP.md b/Examples/CelestraCloud/.claude/CLOUDKIT_SCHEMA_SETUP.md similarity index 100% rename from Examples/Celestra/CLOUDKIT_SCHEMA_SETUP.md rename to Examples/CelestraCloud/.claude/CLOUDKIT_SCHEMA_SETUP.md diff --git a/Examples/Celestra/IMPLEMENTATION_NOTES.md b/Examples/CelestraCloud/.claude/IMPLEMENTATION_NOTES.md similarity index 97% rename from Examples/Celestra/IMPLEMENTATION_NOTES.md rename to Examples/CelestraCloud/.claude/IMPLEMENTATION_NOTES.md index b5343f2d..3d564452 100644 --- a/Examples/Celestra/IMPLEMENTATION_NOTES.md +++ b/Examples/CelestraCloud/.claude/IMPLEMENTATION_NOTES.md @@ -73,7 +73,7 @@ RECORD TYPE Feed ( - `lastModified`: HTTP Last-Modified header for conditional requests - `etag`: HTTP ETag header for conditional requests -- `failureCount`: Consecutive failure counter for circuit breaker pattern +- `failureCount`: Consecutive failure counter for retry tracking - `lastFailureReason`: Last error message for debugging - `minUpdateInterval`: Minimum seconds between updates (from RSS `` or syndication metadata) @@ -536,7 +536,7 @@ func updateArticles(_ articles: [PublicArticle]) async throws -> BatchOperationR ## Web Etiquette Features -Celestra implements comprehensive web etiquette best practices to be a respectful RSS feed client. +CelestraCloud demonstrates comprehensive web etiquette best practices using services from the CelestraKit package. These services (`RateLimiter` and `RobotsTxtService`) are maintained in CelestraKit to enable reuse across the Celestra ecosystem. ### Rate Limiting @@ -557,7 +557,7 @@ celestra update --delay 5.0 ``` **Technical Details**: -- Implemented via `RateLimiter` Actor for thread-safe delay tracking +- Uses `RateLimiter` actor from CelestraKit for thread-safe delay tracking - Per-domain tracking prevents hammering same server - Async/await pattern ensures non-blocking operation @@ -581,7 +581,7 @@ celestra update --skip-robots-check ``` **Technical Details**: -- Implemented via `RobotsTxtService` Actor +- Uses `RobotsTxtService` actor from CelestraKit - Parses User-Agent sections, Disallow rules, and Crawl-delay directives - Network errors default to "allow" rather than blocking feeds @@ -613,7 +613,7 @@ HTTP/1.1 304 Not Modified ### Failure Tracking -**Implementation**: Tracks consecutive failures per feed for circuit breaker pattern. +**Implementation**: Tracks consecutive failures per feed for retry management. **Feed Model Fields**: - `failureCount: Int64` - Consecutive failure counter @@ -776,23 +776,6 @@ fields["feed"] = .reference(FieldValue.Reference(recordName: feedRecordName)) **Decision**: Kept string-based for educational simplicity and explicit code patterns. For production apps handling complex relationship graphs, CKReference is recommended. -**3. Circuit Breaker Pattern**: -For feeds with persistent failures: -```swift -actor CircuitBreaker { - private var failureCount = 0 - private let threshold = 5 - - var isOpen: Bool { - failureCount >= threshold - } - - func recordFailure() { - failureCount += 1 - } -} -``` - ## Implementation Timeline **Phase 1** (Completed): @@ -818,7 +801,7 @@ actor CircuitBreaker { - ✅ Web etiquette: Rate limiting with configurable delays - ✅ Web etiquette: Robots.txt checking and parsing - ✅ Web etiquette: Conditional HTTP requests (If-Modified-Since/ETag) -- ✅ Web etiquette: Failure tracking for circuit breaker pattern +- ✅ Web etiquette: Failure tracking for retry management - ✅ Web etiquette: Custom User-Agent header - ✅ Feed update interval infrastructure (`minUpdateInterval`) diff --git a/Examples/CelestraCloud/.claude/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md b/Examples/CelestraCloud/.claude/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md new file mode 100644 index 00000000..d3c4c9b8 --- /dev/null +++ b/Examples/CelestraCloud/.claude/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md @@ -0,0 +1,13333 @@ + + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration + +Library + +# Configuration + +A Swift library for reading configuration in applications and libraries. + +## Overview + +Swift Configuration defines an abstraction between configuration _readers_ and _providers_. + +Applications and libraries _read_ configuration through a consistent API, while the actual _provider_ is set up once at the application’s entry point. + +For example, to read the timeout configuration value for an HTTP client, check out the following examples using different providers: + +# Environment variables: +HTTP_TIMEOUT=30 +let provider = EnvironmentVariablesProvider() +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +# Program invoked with: +program --http-timeout 30 +let provider = CommandLineArgumentsProvider() +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +{ +"http": { +"timeout": 30 +} +} + +filePath: "/etc/config.json" +) +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +{ +"http": { +"timeout": 30 +} +} + +filePath: "/etc/config.json" +) +// Omitted: Add `provider` to a ServiceGroup +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +http: +timeout: 30 + +filePath: "/etc/config.yaml" +) +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +http: +timeout: 30 + +filePath: "/etc/config.yaml" +) +// Omitted: Add `provider` to a ServiceGroup +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +/ +|-- run +|-- secrets +|-- http-timeout + +Contents of the file `/run/secrets/http-timeout`: `30`. + +let provider = try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +// Environment variables consulted first, then JSON. +let primaryProvider = EnvironmentVariablesProvider() + +filePath: "/etc/config.json" +) +let config = ConfigReader(providers: [\ +primaryProvider,\ +secondaryProvider\ +]) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 +let provider = InMemoryProvider(values: [\ +"http.timeout": 30,\ +]) +let config = ConfigReader(provider: provider) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print(httpTimeout) // prints 30 + +For a selection of more detailed examples, read through Example use cases. + +For a video introduction, check out our talk on YouTube. + +These providers can be combined to form a hierarchy, for details check out Provider hierarchy. + +### Quick start + +Add the dependency to your `Package.swift`: + +.package(url: "https://github.com/apple/swift-configuration", from: "1.0.0") + +Add the library dependency to your target: + +.product(name: "Configuration", package: "swift-configuration") + +Import and use in your code: + +import Configuration + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) +print("The HTTP timeout is: \(httpTimeout)") + +### Package traits + +This package offers additional integrations you can enable using package traits. To enable an additional trait on the package, update the package dependency: + +.package( +url: "https://github.com/apple/swift-configuration", +from: "1.0.0", ++ traits: [.defaults, "YAML"] +) + +Available traits: + +- **`JSON`** (default): Adds support for `JSONSnapshot`, which enables using `FileProvider` and `ReloadingFileProvider` with JSON files. + +- **`Logging`** (opt-in): Adds support for `AccessLogger`, a way to emit access events into a Swift Log `Logger`. + +- **`Reloading`** (opt-in): Adds support for `ReloadingFileProvider`, which provides auto-reloading capability for file-based configuration. + +- **`CommandLineArguments`** (opt-in): Adds support for `CommandLineArgumentsProvider` for parsing command line arguments. + +- **`YAML`** (opt-in): Adds support for `YAMLSnapshot`, which enables using `FileProvider` and `ReloadingFileProvider` with YAML files. + +### Supported platforms and minimum versions + +The library is supported on Apple platforms and Linux. + +| Component | macOS | Linux | iOS | tvOS | watchOS | visionOS | +| --- | --- | --- | --- | --- | --- | --- | +| Configuration | ✅ 15+ | ✅ | ✅ 18+ | ✅ 18+ | ✅ 11+ | ✅ 2+ | + +#### Three access patterns + +The library provides three distinct ways to read configuration values: + +- **Get**: Synchronously return the current value available locally, in memory: + +let timeout = config.int(forKey: "http.timeout", default: 60) + +- **Fetch**: Asynchronously get the most up-to-date value from disk or a remote server: + +let timeout = try await config.fetchInt(forKey: "http.timeout", default: 60) + +- **Watch**: Receive updates when a configuration value changes: + +try await config.watchInt(forKey: "http.timeout", default: 60) { updates in +for try await timeout in updates { +print("HTTP timeout updated to: \(timeout)") +} +} + +For detailed guidance on when to use each access pattern, see Choosing the access pattern. Within each of the access patterns, the library offers different reader methods that reflect your needs of optional, default, and required configuration parameters. To understand the choices available, see Choosing reader methods. + +#### Providers + +The library includes comprehensive built-in provider support: + +- Environment variables: `EnvironmentVariablesProvider` + +- Command-line arguments: `CommandLineArgumentsProvider` + +- JSON file: `FileProvider` and `ReloadingFileProvider` with `JSONSnapshot` + +- YAML file: `FileProvider` and `ReloadingFileProvider` with `YAMLSnapshot` + +- Directory of files: `DirectoryFilesProvider` + +- In-memory: `InMemoryProvider` and `MutableInMemoryProvider` + +- Key transforming: `KeyMappingProvider` + +You can also implement a custom `ConfigProvider`. + +#### Provider hierarchy + +In addition to using providers individually, you can create fallback behavior using an array of providers. The first provider that returns a non-nil value wins. + +The following example shows a provider hierarchy where environment variables take precedence over command line arguments, a JSON file, and in-memory defaults: + +// Create a hierarchy of providers with fallback behavior. +let config = ConfigReader(providers: [\ +// First, check environment variables.\ +EnvironmentVariablesProvider(),\ +// Then, check command-line options.\ +CommandLineArgumentsProvider(),\ +// Then, check a JSON config file.\ + +// Finally, fall \ +]) + +// Uses the first provider that has a value for "http.timeout". +let timeout = config.int(forKey: "http.timeout", default: 15) + +#### Hot reloading + +Long-running services can periodically reload configuration with `ReloadingFileProvider`: + +// Omitted: add provider to a ServiceGroup +let config = ConfigReader(provider: provider) + +Read Using reloading providers for details on how to receive updates as configuration changes. + +#### Namespacing and scoped readers + +The built-in namespacing of `ConfigKey` interprets `"http.timeout"` as an array of two components: `"http"` and `"timeout"`. The following example uses `scoped(to:)` to create a namespaced reader with the key `"http"`, to allow reads to use the shorter key `"timeout"`: + +Consider the following JSON configuration: + +{ +"http": { +"timeout": 60 +} +} +// Create the root reader. +let config = ConfigReader(provider: provider) + +// Create a scoped reader for HTTP settings. +let httpConfig = config.scoped(to: "http") + +// Now you can access values with shorter keys. +// Equivalent to reading "http.timeout" on the root reader. +let timeout = httpConfig.int(forKey: "timeout") + +#### Debugging and troubleshooting + +Debugging with `AccessReporter` makes it possible to log all accesses to a config reader: + +let logger = Logger(label: "config") +let config = ConfigReader( +provider: provider, +accessReporter: AccessLogger(logger: logger) +) +// Now all configuration access is logged, with secret values redacted + +You can also add the following environment variable, and emit log accesses into a file without any code changes: + +CONFIG_ACCESS_LOG_FILE=/var/log/myapp/config-access.log + +and then read the file: + +tail -f /var/log/myapp/config-access.log + +Check out the built-in `AccessLogger`, `FileAccessLogger`, and Troubleshooting and access reporting. + +#### Secrets handling + +The library provides built-in support for handling sensitive configuration values securely: + +// Mark sensitive values as secrets to prevent them from appearing in logs +let privateKey = try snapshot.requiredString(forKey: "mtls.privateKey", isSecret: true) +let optionalAPIToken = config.string(forKey: "api.token", isSecret: true) + +When values are marked as secrets, they are automatically redacted from access logs and debugging output. Read Handling secrets correctly for guidance on best practices for secrets management. + +#### Consistent snapshots + +Retrieve related values from a consistent snapshot using `ConfigSnapshotReader`, which you get by calling `snapshot()`. + +This ensures that multiple values are read from a single snapshot inside each provider, even when using providers that update their internal values. For example by downloading new data periodically: + +let config = /* a reader with one or more providers that change values over time */ +let snapshot = config.snapshot() +let certificate = try snapshot.requiredString(forKey: "mtls.certificate") +let privateKey = try snapshot.requiredString(forKey: "mtls.privateKey", isSecret: true) +// `certificate` and `privateKey` are guaranteed to come from the same snapshot in the provider + +#### Extensible ecosystem + +Any package can implement a `ConfigProvider`, making the ecosystem extensible for custom configuration sources. + +## Topics + +### Essentials + +Configuring applications + +Provide flexible and consistent configuration for your application. + +Configuring libraries + +Provide a consistent and flexible way to configure your library. + +Example use cases + +Review common use cases with ready-to-copy code samples. + +Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +### Configuration keys + +`struct ConfigKey` + +A configuration key representing a relative path to a configuration value. + +`struct AbsoluteConfigKey` + +A configuration key that represents an absolute path to a configuration value. + +`enum ConfigContextValue` + +A value that can be stored in a configuration context. + +### Troubleshooting and access reporting + +Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +### Value conversion + +`protocol ExpressibleByConfigString` + +A protocol for types that can be initialized from configuration string values. + +`protocol ConfigBytesFromStringDecoder` + +A protocol for decoding string configuration values into byte arrays. + +`struct ConfigBytesFromBase64StringDecoder` + +A decoder that converts base64-encoded strings into byte arrays. + +`struct ConfigBytesFromHexStringDecoder` + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +### Contributing + +Developing Swift Configuration + +Learn about tools and conventions used to develop the Swift Configuration package. + +Collaborate on API changes to Swift Configuration by writing a proposal. + +### Extended Modules + +Foundation + +SystemPackage + +- Configuration +- Overview +- Quick start +- Package traits +- Supported platforms and minimum versions +- Key features +- Topics + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/handling-secrets-correctly + +- Configuration +- Handling secrets correctly + +Article + +# Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +## Overview + +Swift Configuration provides built-in support for marking sensitive values as secrets. Secret values are automatically redacted by access reporters to prevent accidental disclosure of API keys, passwords, and other sensitive information. + +### Marking values as secret when reading + +Use the `isSecret` parameter on any configuration reader method to mark a value as secret: + +let config = ConfigReader(provider: provider) + +// Mark sensitive values as secret +let apiKey = try config.requiredString( +forKey: "api.key", +isSecret: true +) +let dbPassword = config.string( +forKey: "database.password", +isSecret: true +) + +// Regular values don't need the parameter +let serverPort = try config.requiredInt(forKey: "server.port") +let logLevel = config.string( +forKey: "log.level", +default: "info" +) + +This works with all access patterns and method variants: + +// Works with fetch and watch too +let latestKey = try await config.fetchRequiredString( +forKey: "api.key", +isSecret: true +) + +try await config.watchString( +forKey: "api.key", +isSecret: true +) { updates in +for await key in updates { +// Handle secret key updates +} +} + +### Provider-level secret specification + +Use `SecretsSpecifier` to automatically mark values as secret based on keys or content when creating providers: + +#### Mark all values as secret + +The following example marks all configuration read by the `DirectoryFilesProvider` as secret: + +let provider = DirectoryFilesProvider( +directoryPath: "/run/secrets", +secretsSpecifier: .all +) + +#### Mark specific keys as secret + +The following example marks three specific keys from a provider as secret: + +let provider = EnvironmentVariablesProvider( +secretsSpecifier: .specific(["API_KEY", "DATABASE_PASSWORD", "JWT_SECRET"]) +) + +#### Dynamic secret detection + +The following example marks keys as secret based on the closure you provide. In this case, keys that contain `password`, `secret`, or `token` are all marked as secret: + +filePath: "/etc/config.json", +secretsSpecifier: .dynamic { key, value in +key.lowercased().contains("password") || +key.lowercased().contains("secret") || +key.lowercased().contains("token") +} +) + +#### No secret values + +The following example asserts that none of the values returned from the provider are considered secret: + +filePath: "/etc/config.json", +secretsSpecifier: .none +) + +### For provider implementors + +When implementing a custom `ConfigProvider`, use the `ConfigValue` type’s `isSecret` property: + +// Create a secret value +let secretValue = ConfigValue("sensitive-data", isSecret: true) + +// Create a regular value +let regularValue = ConfigValue("public-data", isSecret: false) + +Set the `isSecret` property to `true` when your provider knows the values are read from a secrets store and must not be logged. + +### How secret values are protected + +Secret values are automatically handled by: + +- **`AccessLogger`** and **`FileAccessLogger`**: Redact secret values in logs. + +print(provider) + +### Best practices + +1. **Mark all sensitive data as secret**: API keys, passwords, tokens, private keys, connection strings. + +2. **Use provider-level specification** when you know which keys are always secret. + +3. **Use reader-level marking** for context-specific secrets or when the same key might be secret in some contexts but not others. + +4. **Be conservative**: When in doubt, mark values as secret. It’s safer than accidentally leaking sensitive data. + +For additional guidance on configuration security and overall best practices, see Adopting best practices. To debug issues with secret redaction in access logs, check out Troubleshooting and access reporting. When selecting between required, optional, and default method variants for secret values, refer to Choosing reader methods. + +## See Also + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +- Handling secrets correctly +- Overview +- Marking values as secret when reading +- Provider-level secret specification +- For provider implementors +- How secret values are protected +- Best practices +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot + +- Configuration +- YAMLSnapshot + +Class + +# YAMLSnapshot + +A snapshot of configuration values parsed from YAML data. + +final class YAMLSnapshot + +YAMLSnapshot.swift + +## Mentioned in + +Using reloading providers + +## Overview + +This class represents a point-in-time view of configuration values. It handles the conversion from YAML types to configuration value types. + +## Usage + +Use with `FileProvider` or `ReloadingFileProvider`: + +let config = ConfigReader(provider: provider) + +## Topics + +### Creating a YAML snapshot + +`convenience init(data: RawSpan, providerName: String, parsingOptions: YAMLSnapshot.ParsingOptions) throws` + +`struct ParsingOptions` + +Custom input configuration for YAML snapshot creation. + +### Snapshot configuration + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +### Instance Properties + +`let providerName: String` + +The name of the provider that created this snapshot. + +## Relationships + +### Conforms To + +- `ConfigSnapshot` +- `FileConfigSnapshot` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- YAMLSnapshot +- Mentioned in +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting + +Library + +# ConfigurationTesting + +A set of testing utilities for Swift Configuration adopters. + +## Overview + +This testing library adds a Swift Testing-based `ConfigProvider` compatibility suite, recommended for implementors of custom configuration providers. + +## Topics + +### Structures + +`struct ProviderCompatTest` + +A comprehensive test suite for validating `ConfigProvider` implementations. + +- ConfigurationTesting +- Overview +- Topics + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accesslogger + +- Configuration +- AccessLogger + +Class + +# AccessLogger + +An access reporter that logs configuration access events using the Swift Log API. + +final class AccessLogger + +AccessLogger.swift + +## Mentioned in + +Handling secrets correctly + +Troubleshooting and access reporting + +Configuring libraries + +## Overview + +This reporter integrates with the Swift Log library to provide structured logging of configuration accesses. Each configuration access generates a log entry with detailed metadata about the operation, making it easy to track configuration usage and debug issues. + +## Package traits + +This type is guarded by the `Logging` package trait. + +## Usage + +Create an access logger and pass it to your configuration reader: + +import Logging + +let logger = Logger(label: "config.access") +let accessLogger = AccessLogger(logger: logger, level: .info) +let config = ConfigReader( +provider: EnvironmentVariablesProvider(), +accessReporter: accessLogger +) + +## Log format + +Each access event generates a structured log entry with metadata including: + +- `kind`: The type of access operation (get, fetch, watch). + +- `key`: The configuration key that was accessed. + +- `location`: The source code location where the access occurred. + +- `value`: The resolved configuration value (redacted for secrets). + +- `counter`: An incrementing counter for tracking access frequency. + +- Provider-specific information for each provider in the hierarchy. + +## Topics + +### Creating an access logger + +`init(logger: Logger, level: Logger.Level, message: Logger.Message)` + +Creates a new access logger that reports configuration access events. + +## Relationships + +### Conforms To + +- `AccessReporter` +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +- AccessLogger +- Mentioned in +- Overview +- Package traits +- Usage +- Log format +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider + +- Configuration +- ReloadingFileProvider + +Class + +# ReloadingFileProvider + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +ReloadingFileProvider.swift + +## Mentioned in + +Using reloading providers + +Choosing the access pattern + +Troubleshooting and access reporting + +## Overview + +`ReloadingFileProvider` is a generic file-based configuration provider that monitors a configuration file for changes and automatically reloads the data when the file is modified. This provider works with different file formats by using different snapshot types that conform to `FileConfigSnapshot`. + +## Usage + +Create a reloading provider by specifying the snapshot type and file path: + +// Using with a JSON snapshot and a custom poll interval + +filePath: "/etc/config.json", +pollInterval: .seconds(30) +) + +// Using with a YAML snapshot + +filePath: "/etc/config.yaml" +) + +## Service integration + +This provider implements the `Service` protocol and must be run within a `ServiceGroup` to enable automatic reloading: + +let serviceGroup = ServiceGroup(services: [provider], logger: logger) +try await serviceGroup.run() + +The provider monitors the file by polling at the specified interval (default: 15 seconds) and notifies any active watchers when changes are detected. + +## Configuration from a reader + +You can also initialize the provider using a configuration reader: + +let envConfig = ConfigReader(provider: EnvironmentVariablesProvider()) + +This expects a `filePath` key in the configuration that specifies the path to the file. For a full list of configuration keys, check out `init(snapshotType:parsingOptions:config:)`. + +## File monitoring + +The provider detects changes by monitoring both file timestamps and symlink target changes. When a change is detected, it reloads the file and notifies all active watchers of the updated configuration values. + +## Topics + +### Creating a reloading file provider + +`convenience init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, allowMissing: Bool, pollInterval: Duration, logger: Logger, metrics: any MetricsFactory) async throws` + +Creates a reloading file provider that monitors the specified file path. + +`convenience init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, config: ConfigReader, logger: Logger, metrics: any MetricsFactory) async throws` + +Creates a reloading file provider using configuration from a reader. + +### Service lifecycle + +`func run() async throws` + +### Monitoring file changes + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +### Instance Properties + +`let providerName: String` + +The human-readable name of the provider. + +## Relationships + +### Conforms To + +- `ConfigProvider` +Conforms when `Snapshot` conforms to `FileConfigSnapshot`. + +- `ServiceLifecycle.Service` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- ReloadingFileProvider +- Mentioned in +- Overview +- Usage +- Service integration +- Configuration from a reader +- File monitoring +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot + +- Configuration +- JSONSnapshot + +Structure + +# JSONSnapshot + +A snapshot of configuration values parsed from JSON data. + +struct JSONSnapshot + +JSONSnapshot.swift + +## Mentioned in + +Example use cases + +Using reloading providers + +## Overview + +This structure represents a point-in-time view of configuration values. It handles the conversion from JSON types to configuration value types. + +## Usage + +Use with `FileProvider` or `ReloadingFileProvider`: + +let config = ConfigReader(provider: provider) + +## Topics + +### Creating a JSON snapshot + +`init(data: RawSpan, providerName: String, parsingOptions: JSONSnapshot.ParsingOptions) throws` + +`struct ParsingOptions` + +Parsing options for JSON snapshot creation. + +### Snapshot configuration + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +### Instance Properties + +`let providerName: String` + +The name of the provider that created this snapshot. + +## Relationships + +### Conforms To + +- `ConfigSnapshot` +- `FileConfigSnapshot` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- JSONSnapshot +- Mentioned in +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider + +- Configuration +- FileProvider + +Structure + +# FileProvider + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +FileProvider.swift + +## Mentioned in + +Example use cases + +Troubleshooting and access reporting + +## Overview + +`FileProvider` is a generic file-based configuration provider that works with different file formats by using different snapshot types that conform to `FileConfigSnapshot`. This allows for a unified interface for reading JSON, YAML, or other structured configuration files. + +## Usage + +Create a provider by specifying the snapshot type and file path: + +// Using with a JSON snapshot + +filePath: "/etc/config.json" +) + +// Using with a YAML snapshot + +filePath: "/etc/config.yaml" +) + +The provider reads the file once during initialization and creates an immutable snapshot of the configuration values. For auto-reloading behavior, use `ReloadingFileProvider`. + +## Configuration from a reader + +You can also initialize the provider using a configuration reader that specifies the file path through environment variables or other configuration sources: + +let envConfig = ConfigReader(provider: EnvironmentVariablesProvider()) + +This expects a `filePath` key in the configuration that specifies the path to the file. For a full list of configuration keys, check out `init(snapshotType:parsingOptions:config:)`. + +## Topics + +### Creating a file provider + +`init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, allowMissing: Bool) async throws` + +Creates a file provider that reads from the specified file path. + +`init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, config: ConfigReader) async throws` + +Creates a file provider using a file path from a configuration reader. + +### Reading configuration files + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +## Relationships + +### Conforms To + +- `ConfigProvider` +Conforms when `Snapshot` conforms to `FileConfigSnapshot`. + +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- FileProvider +- Mentioned in +- Overview +- Usage +- Configuration from a reader +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/example-use-cases + +- Configuration +- Example use cases + +Article + +# Example use cases + +Review common use cases with ready-to-copy code samples. + +## Overview + +For complete working examples with step-by-step instructions, see the Examples directory in the repository. + +### Reading from environment variables + +Use `EnvironmentVariablesProvider` to read configuration values from environment variables where your app launches. The following example creates a `ConfigReader` with an environment variable provider, and reads the key `server.port`, providing a default value of `8080`: + +import Configuration + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let port = config.int(forKey: "server.port", default: 8080) + +The default environment key encoder uses an underscore to separate key components, making the environment variable name above `SERVER_PORT`. + +### Reading from a JSON configuration file + +You can store multiple configuration values together in a JSON file and read them from the fileystem using `FileProvider` with `JSONSnapshot`. The following example creates a `ConfigReader` for a JSON file at the path `/etc/config.json`, and reads a url and port number collected as properties of the `database` JSON object: + +let config = ConfigReader( + +) + +// Access nested values using dot notation. +let databaseURL = config.string(forKey: "database.url", default: "localhost") +let databasePort = config.int(forKey: "database.port", default: 5432) + +The matching JSON for this configuration might look like: + +{ +"database": { +"url": "localhost", +"port": 5432 +} +} + +### Reading from a directory of secret files + +Use the `DirectoryFilesProvider` to read multiple values collected together in a directory on the fileystem, each in a separate file. The default directory key encoder uses a hyphen in the filename to separate key components. The following example uses the directory `/run/secrets` as a base, and reads the file `database-password` as the key `database.password`: + +// Common pattern for secrets downloaded by an init container. +let config = ConfigReader( +provider: try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) +) + +// Reads the file `/run/secrets/database-password` +let dbPassword = config.string(forKey: "database.password") + +This pattern is useful for reading secrets that your infrastructure makes available on the file system, such as Kubernetes secrets mounted into a container’s filesystem. + +### Handling optional configuration files + +File-based providers support an `allowMissing` parameter to control whether missing files should throw an error or be treated as empty configuration. This is useful when configuration files are optional. + +When `allowMissing` is `false` (the default), missing files throw an error: + +// This will throw an error if config.json doesn't exist +let config = ConfigReader( + +filePath: "/etc/config.json", +allowMissing: false // This is the default +) +) + +When `allowMissing` is `true`, missing files are treated as empty configuration: + +// This won't throw if config.json is missing - treats it as empty +let config = ConfigReader( + +filePath: "/etc/config.json", +allowMissing: true +) +) + +// Returns the default value if the file is missing +let port = config.int(forKey: "server.port", default: 8080) + +The same applies to other file-based providers: + +// Optional secrets directory +let secretsConfig = ConfigReader( +provider: try await DirectoryFilesProvider( +directoryPath: "/run/secrets", +allowMissing: true +) +) + +// Optional environment file +let envConfig = ConfigReader( +provider: try await EnvironmentVariablesProvider( +environmentFilePath: "/etc/app.env", +allowMissing: true +) +) + +// Optional reloading configuration +let reloadingConfig = ConfigReader( + +filePath: "/etc/dynamic-config.yaml", +allowMissing: true +) +) + +### Setting up a fallback hierarchy + +Use multiple providers together to provide a configuration hierarchy that can override values at different levels. The following example uses both an environment variable provider and a JSON provider together, with values from environment variables overriding values from the JSON file. In this example, the defaults are provided using an `InMemoryProvider`, which are only read if the environment variable or the JSON key don’t exist: + +let config = ConfigReader(providers: [\ +// First check environment variables.\ +EnvironmentVariablesProvider(),\ +// Then check the config file.\ + +// Finally, use hardcoded defaults.\ +InMemoryProvider(values: [\ +"app.name": "MyApp",\ +"server.port": 8080,\ +"logging.level": "info"\ +])\ +]) + +### Fetching a value from a remote source + +You can host dynamic configuration that your app can retrieve remotely and use either the “fetch” or “watch” access pattern. The following example uses the “fetch” access pattern to asynchronously retrieve a configuration from the remote provider: + +let myRemoteProvider = MyRemoteProvider(...) +let config = ConfigReader(provider: myRemoteProvider) + +// Makes a network call to retrieve the up-to-date value. +let samplingRatio = try await config.fetchDouble(forKey: "sampling.ratio") + +### Watching for configuration changes + +You can periodically update configuration values using a reloading provider. The following example reloads a YAML file from the filesystem every 30 seconds, and illustrates using `watchInt(forKey:isSecret:fileID:line:updatesHandler:)` to provide an async sequence of updates that you can apply. + +import Configuration +import ServiceLifecycle + +// Create a reloading YAML provider + +filePath: "/etc/app-config.yaml", +pollInterval: .seconds(30) +) +// Omitted: add `provider` to the ServiceGroup. + +let config = ConfigReader(provider: provider) + +// Watch for timeout changes and update HTTP client configuration. +// Needs to run in a separate task from the provider. +try await config.watchInt(forKey: "http.requestTimeout", default: 30) { updates in +for await timeout in updates { +print("HTTP request timeout updated: \(timeout)s") +// Update HTTP client timeout configuration in real-time +} +} + +For details on reloading providers and ServiceLifecycle integration, see Using reloading providers. + +### Prefixing configuration keys + +In most cases, the configuration key provided by the reader can be directly used by the provided, for example `http.timeout` used as the environment variable name `HTTP_TIMEOUT`. + +Sometimes you might need to transform the incoming keys in some way, before they get delivered to the provider. A common example is prefixing each key with a constant prefix, for example `myapp`, turning the key `http.timeout` to `myapp.http.timeout`. + +You can use `KeyMappingProvider` and related extensions on `ConfigProvider` to achieve that. + +The following example uses the key mapping provider to adjust an environment variable provider to look for keys with the prefix `myapp`: + +// Create a base provider for environment variables +let envProvider = EnvironmentVariablesProvider() + +// Wrap it with a key mapping provider to automatically prepend "myapp." to all keys +let prefixedProvider = envProvider.prefixKeys(with: "myapp") + +let config = ConfigReader(provider: prefixedProvider) + +// This reads from the "MYAPP_DATABASE_URL" environment variable. +let databaseURL = config.string(forKey: "database.url", default: "localhost") + +For more configuration guidance, see Adopting best practices. To understand different reader method variants, check out Choosing reader methods. + +## See Also + +### Essentials + +Configuring applications + +Provide flexible and consistent configuration for your application. + +Configuring libraries + +Provide a consistent and flexible way to configure your library. + +Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +- Example use cases +- Overview +- Reading from environment variables +- Reading from a JSON configuration file +- Reading from a directory of secret files +- Handling optional configuration files +- Setting up a fallback hierarchy +- Fetching a value from a remote source +- Watching for configuration changes +- Prefixing configuration keys +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/scoped(to:) + +#app-main) + +- Configuration +- ConfigReader +- scoped(to:) + +Instance Method + +# scoped(to:) + +Returns a scoped config reader with the specified key appended to the current prefix. + +ConfigReader.swift + +## Parameters + +`configKey` + +The key components to append to the current key prefix. + +## Return Value + +A config reader for accessing values within the specified scope. + +## Discussion + +let httpConfig = config.scoped(to: ConfigKey(["http", "client"])) +let timeout = httpConfig.int(forKey: "timeout", default: 30) // Reads "http.client.timeout" + +- scoped(to:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider + +- Configuration +- EnvironmentVariablesProvider + +Structure + +# EnvironmentVariablesProvider + +A configuration provider that sources values from environment variables. + +struct EnvironmentVariablesProvider + +EnvironmentVariablesProvider.swift + +## Mentioned in + +Troubleshooting and access reporting + +Configuring applications + +Example use cases + +## Overview + +This provider reads configuration values from environment variables, supporting both the current process environment and `.env` files. It automatically converts hierarchical configuration keys into standard environment variable naming conventions and handles type conversion for all supported configuration value types. + +## Key transformation + +Configuration keys are transformed into environment variable names using these rules: + +- Components are joined with underscores + +- All characters are converted to uppercase + +- CamelCase is detected and word boundaries are marked with underscores + +- Non-alphanumeric characters are replaced with underscores + +For example: `http.serverTimeout` becomes `HTTP_SERVER_TIMEOUT` + +## Supported data types + +The provider supports all standard configuration types: + +- Strings, integers, doubles, and booleans + +- Arrays of strings, integers, doubles, and booleans (comma-separated by default) + +- Byte arrays (base64-encoded by default) + +- Arrays of byte chunks + +## Secret handling + +Environment variables can be marked as secrets using a `SecretsSpecifier`. Secret values are automatically redacted in debug output and logging. + +## Usage + +### Reading environment variables in the current process + +// Assuming the environment contains the following variables: +// HTTP_CLIENT_USER_AGENT=Config/1.0 (Test) +// HTTP_CLIENT_TIMEOUT=15.0 +// HTTP_SECRET=s3cret +// HTTP_VERSION=2 +// ENABLED=true + +let provider = EnvironmentVariablesProvider( +secretsSpecifier: .specific(["HTTP_SECRET"]) +) +// Prints all values, redacts "HTTP_SECRET" automatically. +print(provider) +let config = ConfigReader(provider: provider) +let isEnabled = config.bool(forKey: "enabled", default: false) +let userAgent = config.string(forKey: "http.client.user-agent", default: "unspecified") +// ... + +### Reading environment variables from a \`.env\`-style file + +// Assuming the local file system has a file called `.env` in the current working directory +// with the following contents: +// +// HTTP_CLIENT_USER_AGENT=Config/1.0 (Test) +// HTTP_CLIENT_TIMEOUT=15.0 +// HTTP_SECRET=s3cret +// HTTP_VERSION=2 +// ENABLED=true + +let provider = try await EnvironmentVariablesProvider( +environmentFilePath: ".env", +secretsSpecifier: .specific(["HTTP_SECRET"]) +) +// Prints all values, redacts "HTTP_SECRET" automatically. +print(provider) +let config = ConfigReader(provider: provider) +let isEnabled = config.bool(forKey: "enabled", default: false) +let userAgent = config.string(forKey: "http.client.user-agent", default: "unspecified") +// ... + +### Config context + +The environment variables provider ignores the context passed in `context`. + +## Topics + +### Creating an environment variable provider + +Creates a new provider that reads from the current process environment. + +Creates a new provider from a custom dictionary of environment variables. + +Creates a new provider that reads from an environment file. + +### Inspecting an environment variable provider + +Returns the raw string value for a specific environment variable name. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- EnvironmentVariablesProvider +- Mentioned in +- Overview +- Key transformation +- Supported data types +- Secret handling +- Usage +- Reading environment variables in the current process +- Reading environment variables from a \`.env\`-style file +- Config context +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey + +- Configuration +- ConfigKey + +Structure + +# ConfigKey + +A configuration key representing a relative path to a configuration value. + +struct ConfigKey + +ConfigKey.swift + +## Overview + +Configuration keys consist of hierarchical string components forming paths similar to file system paths or JSON object keys. For example, `["http", "timeout"]` represents the `timeout` value nested under `http`. + +Keys support additional context information that providers can use to refine lookups or provide specialized behavior. + +## Usage + +Create keys using string literals, arrays, or the initializers: + +let key1: ConfigKey = "database.connection.timeout" +let key2 = ConfigKey(["api", "endpoints", "primary"]) +let key3 = ConfigKey("server.port", context: ["environment": .string("production")]) + +## Topics + +### Creating a configuration key + +[`init(String, context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-6vten) + +Creates a new configuration key. + +[`init([String], context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-9ifez) + +### Inspecting a configuration key + +[`var components: [String]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/components) + +The hierarchical components that make up this configuration key. + +[`var context: [String : ConfigContextValue]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/context) + +Additional context information for this configuration key. + +## Relationships + +### Conforms To + +- `Swift.Comparable` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` +- `Swift.Equatable` +- `Swift.ExpressibleByArrayLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Configuration keys + +`struct AbsoluteConfigKey` + +A configuration key that represents an absolute path to a configuration value. + +`enum ConfigContextValue` + +A value that can be stored in a configuration context. + +- ConfigKey +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider + +- Configuration +- CommandLineArgumentsProvider + +Structure + +# CommandLineArgumentsProvider + +A configuration provider that sources values from command-line arguments. + +struct CommandLineArgumentsProvider + +CommandLineArgumentsProvider.swift + +## Overview + +Reads configuration values from CLI arguments with type conversion and secrets handling. Keys are encoded to CLI flags at lookup time. + +## Package traits + +This type is guarded by the `CommandLineArgumentsSupport` package trait. + +## Key formats + +- `--key value` \- A key-value pair with separate arguments. + +- `--key=value` \- A key-value pair with an equals sign. + +- `--flag` \- A Boolean flag, treated as `true`. + +- `--key val1 val2` \- Multiple values (arrays). + +Configuration keys are transformed to CLI flags: `["http", "serverTimeout"]` → `--http-server-timeout`. + +## Array handling + +Arrays can be specified in multiple ways: + +- **Space-separated**: `--tags swift configuration cli` + +- **Repeated flags**: `--tags swift --tags configuration --tags cli` + +- **Comma-separated**: `--tags swift,configuration,cli` + +- **Mixed**: `--tags swift,configuration --tags cli` + +All formats produce the same result when accessed as an array type. + +## Usage + +// CLI: program --debug --host localhost --ports 8080 8443 +let provider = CommandLineArgumentsProvider() +let config = ConfigReader(provider: provider) + +let isDebug = config.bool(forKey: "debug", default: false) // true +let host = config.string(forKey: "host", default: "0.0.0.0") // "localhost" +let ports = config.intArray(forKey: "ports", default: []) // [8080, 8443] + +### With secrets + +let provider = CommandLineArgumentsProvider( +secretsSpecifier: .specific(["--api-key"]) +) + +### Custom arguments + +let provider = CommandLineArgumentsProvider( +arguments: ["program", "--verbose", "--timeout", "30"], +secretsSpecifier: .dynamic { key, _ in key.contains("--secret") } +) + +## Topics + +### Creating a command line arguments provider + +Creates a new CLI provider with the provided arguments. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- CommandLineArgumentsProvider +- Overview +- Package traits +- Key formats +- Array handling +- Usage +- With secrets +- Custom arguments +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/choosing-reader-methods + +- Configuration +- Choosing reader methods + +Article + +# Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +## Overview + +For every configuration access pattern (get, fetch, watch) and data type, Swift Configuration provides three method variants that handle missing or invalid values differently: + +- **Optional variant**: Returns `nil` when a value is missing or cannot be converted. + +- **Default variant**: Returns a fallback value when a value is missing or cannot be converted. + +- **Required variant**: Throws an error when a value is missing or cannot be converted. + +Understanding these variants helps you write robust configuration code that handles missing values appropriately for your use case. + +### Optional variants + +Optional variants return `nil` when a configuration value is missing or cannot be converted to the expected type. These methods have the simplest signatures and are ideal when configuration values are truly optional. + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) + +// Optional get +let timeout: Int? = config.int(forKey: "http.timeout") +let apiUrl: String? = config.string(forKey: "api.url") + +// Optional fetch +let latestTimeout: Int? = try await config.fetchInt(forKey: "http.timeout") + +// Optional watch +try await config.watchInt(forKey: "http.timeout") { updates in +for await timeout in updates { +if let timeout = timeout { +print("Timeout is set to: \(timeout)") +} else { +print("No timeout configured") +} +} +} + +#### When to use + +Use optional variants when: + +- **Truly optional features**: The configuration controls optional functionality. + +- **Gradual rollouts**: New configuration that might not be present everywhere. + +- **Conditional behavior**: Your code can operate differently based on presence or absence. + +- **Debugging and diagnostics**: You want to detect missing configuration explicitly. + +#### Error handling behavior + +Optional variants handle errors gracefully by returning `nil`: + +- Missing values return `nil`. + +- Type conversion errors return `nil`. + +- Provider errors return `nil` (except for fetch variants, which always propagate provider errors). + +// These all return nil instead of throwing +let missingPort = config.int(forKey: "nonexistent.port") // nil +let invalidPort = config.int(forKey: "invalid.port.value") // nil (if value can't convert to Int) +let failingPort = config.int(forKey: "provider.error.key") // nil (if provider fails) + +// Fetch variants still throw provider errors +do { +let port = try await config.fetchInt(forKey: "network.error") // Throws provider error +} catch { +// Handle network or provider errors +} + +### Default variants + +Default variants return a specified fallback value when a configuration value is missing or cannot be converted. These provide guaranteed non-optional results while handling missing configuration gracefully. + +// Default get +let timeout = config.int(forKey: "http.timeout", default: 30) +let retryCount = config.int(forKey: "network.retries", default: 3) + +// Default fetch +let latestTimeout = try await config.fetchInt(forKey: "http.timeout", default: 30) + +// Default watch +try await config.watchInt(forKey: "http.timeout", default: 30) { updates in +for await timeout in updates { +print("Using timeout: \(timeout)") // Always has a value +connectionManager.setTimeout(timeout) +} +} + +#### When to use + +Use default variants when: + +- **Sensible defaults exist**: You have reasonable fallback values for missing configuration. + +- **Simplified code flow**: You want to avoid optional handling in business logic. + +- **Required functionality**: The feature needs a value to operate, but can use defaults. + +- **Configuration evolution**: New settings that should work with older deployments. + +#### Choosing good defaults + +Consider these principles when choosing default values: + +// Safe defaults that won't cause issues +let timeout = config.int(forKey: "http.timeout", default: 30) // Reasonable timeout +let maxRetries = config.int(forKey: "retries.max", default: 3) // Conservative retry count +let cacheSize = config.int(forKey: "cache.size", default: 1000) // Modest cache size + +// Environment-specific defaults +let logLevel = config.string(forKey: "log.level", default: "info") // Safe default level +let enableDebug = config.bool(forKey: "debug.enabled", default: false) // Secure default + +// Performance defaults that err on the side of caution +let batchSize = config.int(forKey: "batch.size", default: 100) // Small safe batch +let maxConnections = config.int(forKey: "pool.max", default: 10) // Conservative pool + +#### Error handling behavior + +Default variants handle errors by returning the default value: + +- Missing values return the default. + +- Type conversion errors return the default. + +- Provider errors return the default (except for fetch variants). + +### Required variants + +Required variants throw errors when configuration values are missing or cannot be converted. These enforce that critical configuration must be present and valid. + +do { +// Required get +let serverPort = try config.requiredInt(forKey: "server.port") +let databaseHost = try config.requiredString(forKey: "database.host") + +// Required fetch +let latestPort = try await config.fetchRequiredInt(forKey: "server.port") + +// Required watch +try await config.watchRequiredInt(forKey: "server.port") { updates in +for try await port in updates { +print("Server port updated to: \(port)") +server.updatePort(port) +} +} +} catch { +fatalError("Configuration error: \(error)") +} + +#### When to use + +Use required variants when: + +- **Essential service configuration**: Server ports, database hosts, service endpoints. + +- **Application startup**: Values needed before the application can function properly. + +- **Critical functionality**: Configuration that must be present for core features to work. + +- **Fail-fast behavior**: You want immediate errors for missing critical configuration. + +### Choosing the right variant + +Use this decision tree to select the appropriate variant: + +#### Is the configuration value critical for application operation? + +**Yes** → Use **required variants** + +// Critical values that must be present +let serverPort = try config.requiredInt(forKey: "server.port") +let databaseHost = try config.requiredString(forKey: "database.host") + +**No** → Continue to next question + +#### Do you have a reasonable default value? + +**Yes** → Use **default variants** + +// Optional features with sensible defaults +let timeout = config.int(forKey: "http.timeout", default: 30) +let retryCount = config.int(forKey: "retries", default: 3) + +**No** → Use **optional variants** + +// Truly optional features where absence is meaningful +let debugEndpoint = config.string(forKey: "debug.endpoint") +let customTheme = config.string(forKey: "ui.theme") + +### Context and type conversion + +All variants support the same additional features: + +#### Configuration context + +// Optional with context +let timeout = config.int( +forKey: ConfigKey( +"service.timeout", +context: ["environment": "production", "region": "us-east-1"] +) +) + +// Default with context +let timeout = config.int( +forKey: ConfigKey( +"service.timeout", +context: ["environment": "production"] +), +default: 30 +) + +// Required with context +let timeout = try config.requiredInt( +forKey: ConfigKey( +"service.timeout", +context: ["environment": "production"] +) +) + +#### Type conversion + +String configuration values can be automatically converted to other types using the `as:` parameter. This works with: + +**Built-in convertible types:** + +- `SystemPackage.FilePath`: Converts from file paths. + +- `Foundation.URL`: Converts from URL strings. + +- `Foundation.UUID`: Converts from UUID strings. + +- `Foundation.Date`: Converts from ISO8601 date strings. + +**String-backed enums:** + +**Custom types:** + +- Types that you explicitly conform to `ExpressibleByConfigString`. + +// Built-in type conversion +let apiUrl = config.string(forKey: "api.url", as: URL.self) +let requestId = config.string(forKey: "request.id", as: UUID.self) +let configPath = config.string(forKey: "config.path", as: FilePath.self) +let startDate = config.string(forKey: "launch.date", as: Date.self) + +enum LogLevel: String { +case debug, info, warning, error +} + +// Optional conversion +let level: LogLevel? = config.string(forKey: "log.level", as: LogLevel.self) + +// Default conversion +let level = config.string(forKey: "log.level", as: LogLevel.self, default: .info) + +// Required conversion +let level = try config.requiredString(forKey: "log.level", as: LogLevel.self) + +// Custom type conversion (ExpressibleByConfigString) +struct DatabaseURL: ExpressibleByConfigString { +let url: URL + +init?(configString: String) { +guard let url = URL(string: configString) else { return nil } +self.url = url +} + +var description: String { url.absoluteString } +} +let dbUrl = config.string(forKey: "database.url", as: DatabaseURL.self) + +#### Secret handling + +// Mark sensitive values as secrets in all variants +let optionalKey = config.string(forKey: "api.key", isSecret: true) +let defaultKey = config.string(forKey: "api.key", isSecret: true, default: "development-key") +let requiredKey = try config.requiredString(forKey: "api.key", isSecret: true) + +Also check out Handling secrets correctly. + +### Best practices + +1. **Use required variants** only for truly critical configuration. + +2. **Use default variants** for user experience settings where missing configuration shouldn’t break functionality. + +3. **Use optional variants** for feature flags and debugging where the absence of configuration is meaningful. + +4. **Choose safe defaults** that won’t cause security issues or performance problems if used in production. + +For guidance on selecting between get, fetch, and watch access patterns, see Choosing the access pattern. For more configuration guidance, check out Adopting best practices. + +## See Also + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +- Choosing reader methods +- Overview +- Optional variants +- Default variants +- Required variants +- Choosing the right variant +- Context and type conversion +- Best practices +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider + +- Configuration +- KeyMappingProvider + +Structure + +# KeyMappingProvider + +A configuration provider that maps all keys before delegating to an upstream provider. + +KeyMappingProvider.swift + +## Mentioned in + +Example use cases + +## Overview + +Use `KeyMappingProvider` to automatically apply a mapping function to every configuration key before passing it to an underlying provider. This is particularly useful when the upstream source of configuration keys differs from your own. Another example is namespacing configuration values from specific sources, such as prefixing environment variables with an application name while leaving other configuration sources unchanged. + +### Common use cases + +Use `KeyMappingProvider` for: + +- Rewriting configuration keys to match upstream configuration sources. + +- Legacy system integration that adapts existing sources with different naming conventions. + +## Example + +Use `KeyMappingProvider` when you want to map keys for specific providers in a multi-provider setup: + +// Create providers +let envProvider = EnvironmentVariablesProvider() + +// Only remap the environment variables, not the JSON config +let keyMappedEnvProvider = KeyMappingProvider(upstream: envProvider) { key in +key.prepending(["myapp", "prod"]) +} + +let config = ConfigReader(providers: [\ +keyMappedEnvProvider, // Reads from "MYAPP_PROD_*" environment variables\ +jsonProvider // Reads from JSON without prefix\ +]) + +// This reads from "MYAPP_PROD_DATABASE_HOST" env var or "database.host" in JSON +let host = config.string(forKey: "database.host", default: "localhost") + +## Convenience method + +You can also use the `prefixKeys(with:)` convenience method on configuration provider types to wrap one in a `KeyMappingProvider`: + +let envProvider = EnvironmentVariablesProvider() +let keyMappedEnvProvider = envProvider.mapKeys { key in +key.prepending(["myapp", "prod"]) +} + +## Topics + +### Creating a key-mapping provider + +Creates a new provider. + +## Relationships + +### Conforms To + +- `ConfigProvider` +Conforms when `Upstream` conforms to `ConfigProvider`. + +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +- KeyMappingProvider +- Mentioned in +- Overview +- Common use cases +- Example +- Convenience method +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/choosing-access-patterns + +- Configuration +- Choosing the access pattern + +Article + +# Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +## Overview + +Swift Configuration provides three access patterns for retrieving configuration values, each optimized for different use cases and performance requirements. + +The three access patterns are: + +- **Get**: Synchronous access to current values available locally, in-memory. + +- **Fetch**: Asynchronous access to retrieve fresh values from authoritative sources, optionally with extra context. + +- **Watch**: Reactive access that provides real-time updates when values change. + +### Get: Synchronous local access + +The “get” pattern provides immediate, synchronous access to configuration values that are already available in memory. This is the fastest and most commonly used access pattern. + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) + +// Get the current timeout value synchronously +let timeout = config.int(forKey: "http.timeout", default: 30) + +// Get a required value that must be present +let apiKey = try config.requiredString(forKey: "api.key", isSecret: true) + +#### When to use + +Use the “get” pattern when: + +- **Performance is critical**: You need immediate access without async overhead. + +- **Values are stable**: Configuration doesn’t change frequently during runtime. + +- **Simple providers**: Using environment variables, command-line arguments, or files. + +- **Startup configuration**: Reading values during application initialization. + +- **Request handling**: Accessing configuration in hot code paths where async calls would add latency. + +#### Behavior characteristics + +- Returns the currently cached value from the provider. + +- No network or I/O operations occur during the call. + +- Values may become stale if the underlying data source changes and the provider is either non-reloading, or has a long reload interval. + +### Fetch: Asynchronous fresh access + +The “fetch” pattern asynchronously retrieves the most current value from the authoritative data source, ensuring you always get up-to-date configuration. + +let config = ConfigReader(provider: remoteConfigProvider) + +// Fetch the latest timeout from a remote configuration service +let timeout = try await config.fetchInt(forKey: "http.timeout", default: 30) + +// Fetch with context for environment-specific configuration +let dbConnectionString = try await config.fetchRequiredString( +forKey: ConfigKey( +"database.url", +context: [\ +"environment": "production",\ +"region": "us-west-2",\ +"service": "user-service"\ +] +), +isSecret: true +) + +#### When to use + +Use the “fetch” pattern when: + +- **Freshness is critical**: You need the latest configuration values. + +- **Remote providers**: Using configuration services, databases, or external APIs that perform evaluation remotely. + +- **Infrequent access**: Reading configuration occasionally, not in hot paths. + +- **Setup operations**: Configuring long-lived resources like database connections where one-time overhead isn’t a concern, and the improved freshness is important. + +- **Administrative operations**: Fetching current settings for management interfaces. + +#### Behavior characteristics + +- Always contacts the authoritative data source. + +- May involve network calls, file system access, or database queries. + +- Providers may (but are not required to) cache the fetched value for subsequent “get” calls. + +- Throws an error if the provider fails to reach the source. + +### Watch: Reactive continuous updates + +The “watch” pattern provides an async sequence of configuration updates, allowing you to react to changes in real-time. This is ideal for long-running services that need to adapt to configuration changes without restarting. + +The async sequence is required to receive the current value as the first element as quickly as possible - this is part of the API contract with configuration providers (for details, check out `ConfigProvider`.) + +let config = ConfigReader(provider: reloadingProvider) + +// Watch for timeout changes and update connection pools +try await config.watchInt(forKey: "http.timeout", default: 30) { updates in +for await newTimeout in updates { +print("HTTP timeout updated to: \(newTimeout)") +connectionPool.updateTimeout(newTimeout) +} +} + +#### When to use + +Use the “watch” pattern when: + +- **Dynamic configuration**: Values change during application runtime. + +- **Hot reloading**: You need to update behavior without restarting the service. + +- **Feature toggles**: Enabling or disabling features based on configuration changes. + +- **Resource management**: Adjusting timeouts, limits, or thresholds dynamically. + +- **A/B testing**: Updating experimental parameters in real-time. + +#### Behavior characteristics + +- Immediately emits the initial value, then subsequent updates. + +- Continues monitoring until the task is cancelled. + +- Works with providers like `ReloadingFileProvider`. + +For details on reloading providers, check out Using reloading providers. + +### Using configuration context + +All access patterns support configuration context, which provides additional metadata to help providers return more specific values. Context is particularly useful with the “fetch” and “watch” patterns when working with dynamic or environment-aware providers. + +#### Filtering watch updates using context + +let context: [String: ConfigContextValue] = [\ +"environment": "production",\ +"region": "us-east-1",\ +"service_version": "2.1.0",\ +"feature_tier": "premium",\ +"load_factor": 0.85\ +] + +// Get environment-specific database configuration +let dbConfig = try await config.fetchRequiredString( +forKey: ConfigKey( +"database.connection_string", +context: context +), +isSecret: true +) + +// Watch for region-specific timeout adjustments +try await config.watchInt( +forKey: ConfigKey( +"api.timeout", +context: ["region": "us-west-2"] +), +default: 5000 +) { updates in +for await timeout in updates { +apiClient.updateTimeout(milliseconds: timeout) +} +} + +#### Get pattern performance + +- **Fastest**: No async overhead, immediate return. + +- **Memory usage**: Minimal, uses cached values. + +- **Best for**: Request handling, hot code paths, startup configuration. + +#### Fetch pattern performance + +- **Moderate**: Async overhead plus data source access time. + +- **Network dependent**: Performance varies with provider implementation. + +- **Best for**: Infrequent access, setup operations, administrative tasks. + +#### Watch pattern performance + +- **Background monitoring**: Continuous resource usage for monitoring. + +- **Event-driven**: Efficient updates only when values change. + +- **Best for**: Long-running services, dynamic configuration, feature toggles. + +### Error handling strategies + +Each access pattern handles errors differently: + +#### Get pattern errors + +// Returns nil or default value for missing/invalid config +let timeout = config.int(forKey: "http.timeout", default: 30) + +// Required variants throw errors for missing values +do { +let apiKey = try config.requiredString(forKey: "api.key") +} catch { +// Handle missing required configuration +} + +#### Fetch pattern errors + +// All fetch methods propagate provider and conversion errors +do { +let config = try await config.fetchRequiredString(forKey: "database.url") +} catch { +// Handle network errors, missing values, or conversion failures +} + +#### Watch pattern errors + +// Errors appear in the async sequence +try await config.watchRequiredInt(forKey: "port") { updates in +do { +for try await port in updates { +server.updatePort(port) +} +} catch { +// Handle provider errors or missing required values +} +} + +### Best practices + +1. **Choose based on use case**: Use “get” for performance-critical paths, “fetch” for freshness, and “watch” for hot reloading. + +2. **Handle errors appropriately**: Design error handling strategies that match your application’s resilience requirements. + +3. **Use context judiciously**: Provide context when you need environment-specific or conditional configuration values. + +4. **Monitor configuration access**: Use `AccessReporter` to understand your application’s configuration dependencies. + +5. **Cache wisely**: For frequently accessed values, prefer “get” over repeated “fetch” calls. + +For more guidance on selecting the right reader methods for your needs, see Choosing reader methods. To learn about handling sensitive configuration values securely, check out Handling secrets correctly. If you encounter issues with configuration access, refer to Troubleshooting and access reporting for debugging techniques. + +## See Also + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +- Choosing the access pattern +- Overview +- Get: Synchronous local access +- Fetch: Asynchronous fresh access +- Watch: Reactive continuous updates +- Using configuration context +- Summary of performance considerations +- Error handling strategies +- Best practices +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessreporter + +- Configuration +- AccessReporter + +Protocol + +# AccessReporter + +A type that receives and processes configuration access events. + +protocol AccessReporter : Sendable + +AccessReporter.swift + +## Mentioned in + +Troubleshooting and access reporting + +Choosing the access pattern + +Configuring libraries + +## Overview + +Access reporters track when configuration values are read, fetched, or watched, to provide visibility into configuration usage patterns. This is useful for debugging, auditing, and understanding configuration dependencies. + +## Topics + +### Required methods + +`func report(AccessEvent)` + +Processes a configuration access event. + +**Required** + +## Relationships + +### Inherits From + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Conforming Types + +- `AccessLogger` +- `BroadcastingAccessReporter` +- `FileAccessLogger` + +## See Also + +### Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +- AccessReporter +- Mentioned in +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/using-reloading-providers + +- Configuration +- Using reloading providers + +Article + +# Using reloading providers + +Automatically reload configuration from files when they change. + +## Overview + +A reloading provider monitors configuration files for changes and automatically updates your application’s configuration without requiring restarts. Swift Configuration provides: + +- `ReloadingFileProvider` with `JSONSnapshot` for JSON configuration files. + +- `ReloadingFileProvider` with `YAMLSnapshot` for YAML configuration files. + +#### Creating and running providers + +Reloading providers run in a `ServiceGroup`: + +import ServiceLifecycle + +filePath: "/etc/config.json", +allowMissing: true, // Optional: treat missing file as empty config +pollInterval: .seconds(15) +) + +let serviceGroup = ServiceGroup( +services: [provider], +logger: logger +) + +try await serviceGroup.run() + +#### Reading configuration + +Use a reloading provider in the same fashion as a static provider, pass it to a `ConfigReader`: + +let config = ConfigReader(provider: provider) +let host = config.string( +forKey: "database.host", +default: "localhost" +) + +#### Poll interval considerations + +Choose poll intervals based on how quickly you need to detect changes: + +// Development: Quick feedback +pollInterval: .seconds(1) + +// Production: Balanced performance (default) +pollInterval: .seconds(15) + +// Batch processing: Resource efficient +pollInterval: .seconds(300) + +### Watching for changes + +The following sections provide examples of watching for changes in configuration from a reloading provider. + +#### Individual values + +The example below watches for updates in a single key, `database.host`: + +try await config.watchString( +forKey: "database.host" +) { updates in +for await host in updates { +print("Database host updated: \(host)") +} +} + +#### Configuration snapshots + +The following example reads the `database.host` and `database.password` key with the guarantee that they are read from the same update of the reloading file: + +try await config.watchSnapshot { updates in +for await snapshot in updates { +let host = snapshot.string(forKey: "database.host") +let password = snapshot.string(forKey: "database.password", isSecret: true) +print("Configuration updated - Database: \(host)") +} +} + +### Comparison with static providers + +| Feature | Static providers | Reloading providers | +| --- | --- | --- | +| **File reading** | Load once at startup | Reloading on change | +| **Service lifecycle** | Not required | Conforms to `Service` and must run in a `ServiceGroup` | +| **Configuration updates** | Require restart | Automatic reload | + +### Handling missing files during reloading + +Reloading providers support the `allowMissing` parameter to handle cases where configuration files might be temporarily missing or optional. This is useful for: + +- Optional configuration files that might not exist in all environments. + +- Configuration files that are created or removed dynamically. + +- Graceful handling of file system issues during service startup. + +#### Missing file behavior + +When `allowMissing` is `false` (the default), missing files cause errors: + +filePath: "/etc/config.json", +allowMissing: false // Default: throw error if file is missing +) +// Will throw an error if config.json doesn't exist + +When `allowMissing` is `true`, missing files are treated as empty configuration: + +filePath: "/etc/config.json", +allowMissing: true // Treat missing file as empty config +) +// Won't throw if config.json is missing - uses empty config instead + +#### Behavior during reloading + +If a file becomes missing after the provider starts, the behavior depends on the `allowMissing` setting: + +- **`allowMissing: false`**: The provider keeps the last known configuration and logs an error. + +- **`allowMissing: true`**: The provider switches to empty configuration. + +In both cases, when a valid file comes back, the provider will load it and recover. + +// Example: File gets deleted during runtime +try await config.watchString(forKey: "database.host", default: "localhost") { updates in +for await host in updates { +// With allowMissing: true, this will receive "localhost" when file is removed +// With allowMissing: false, this keeps the last known value +print("Database host: \(host)") +} +} + +#### Configuration-driven setup + +The following example sets up an environment variable provider to select the path and interval to watch for a JSON file that contains the configuration for your app: + +let envProvider = EnvironmentVariablesProvider() +let envConfig = ConfigReader(provider: envProvider) + +config: envConfig.scoped(to: "json") +// Reads JSON_FILE_PATH and JSON_POLL_INTERVAL_SECONDS +) + +### Migration from static providers + +1. **Replace initialization**: + +// Before + +// After + +2. **Add the provider to a ServiceGroup**: + +let serviceGroup = ServiceGroup(services: [provider], logger: logger) +try await serviceGroup.run() + +3. **Use ConfigReader**: + +let config = ConfigReader(provider: provider) + +// Live updates. +try await config.watchDouble(forKey: "timeout") { updates in +// Handle changes +} + +// On-demand reads - returns the current value, so might change over time. +let timeout = config.double(forKey: "timeout", default: 60.0) + +For guidance on choosing between get, fetch, and watch access patterns with reloading providers, see Choosing the access pattern. For troubleshooting reloading provider issues, check out Troubleshooting and access reporting. To learn about in-memory providers as an alternative, see Using in-memory providers. + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- Using reloading providers +- Overview +- Basic usage +- Watching for changes +- Comparison with static providers +- Handling missing files during reloading +- Advanced features +- Migration from static providers +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider + +- Configuration +- MutableInMemoryProvider + +Class + +# MutableInMemoryProvider + +A configuration provider that stores mutable values in memory. + +final class MutableInMemoryProvider + +MutableInMemoryProvider.swift + +## Mentioned in + +Using in-memory providers + +## Overview + +Unlike `InMemoryProvider`, this provider allows configuration values to be modified after initialization. It maintains thread-safe access to values and supports real-time notifications when values change, making it ideal for dynamic configuration scenarios. + +## Change notifications + +The provider supports watching for configuration changes through the standard `ConfigProvider` watching methods. When a value changes, all active watchers are automatically notified with the new value. + +## Use cases + +The mutable in-memory provider is particularly useful for: + +- **Dynamic configuration**: Values that change during application runtime + +- **Configuration bridges**: Adapting external configuration systems that push updates + +- **Testing scenarios**: Simulating configuration changes in unit tests + +- **Feature flags**: Runtime toggles that can be modified programmatically + +## Performance characteristics + +This provider offers O(1) lookup time with minimal synchronization overhead. Value updates are atomic and efficiently notify only the relevant watchers. + +## Usage + +// Create provider with initial values +let provider = MutableInMemoryProvider(initialValues: [\ +"feature.enabled": true,\ +"api.timeout": 30.0,\ +"database.host": "localhost"\ +]) + +let config = ConfigReader(provider: provider) + +// Read initial values +let isEnabled = config.bool(forKey: "feature.enabled") // true + +// Update values dynamically +provider.setValue(false, forKey: "feature.enabled") + +// Read updated values +let stillEnabled = config.bool(forKey: "feature.enabled") // false + +To learn more about the in-memory providers, check out Using in-memory providers. + +## Topics + +### Creating a mutable in-memory provider + +[`init(name: String?, initialValues: [AbsoluteConfigKey : ConfigValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/init(name:initialvalues:)) + +Creates a new mutable in-memory provider with the specified initial values. + +### Updating values in a mutable in-memory provider + +`func setValue(ConfigValue?, forKey: AbsoluteConfigKey)` + +Updates the stored value for the specified configuration key. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- MutableInMemoryProvider +- Mentioned in +- Overview +- Change notifications +- Use cases +- Performance characteristics +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/development + +- Configuration +- Developing Swift Configuration + +Article + +# Developing Swift Configuration + +Learn about tools and conventions used to develop the Swift Configuration package. + +## Overview + +The Swift Configuration package is developed using modern Swift development practices and tools. This guide covers the development workflow, code organization, and tooling used to maintain the package. + +### Process + +We follow an open process and discuss development on GitHub issues, pull requests, and on the Swift Forums. Details on how to submit an issue or a pull requests can be found in CONTRIBUTING.md. + +Large features and changes go through a lightweight proposals process - to learn more, check out Proposals. + +#### Package organization + +The package contains several Swift targets organized by functionality: + +- **Configuration** \- Core configuration reading APIs and built-in providers. + +- **ConfigurationTesting** \- Testing utilities for external configuration providers. + +- **ConfigurationTestingInternal** \- Internal testing utilities and helpers. + +#### Running CI checks locally + +You can run the Github Actions workflows locally using act. To run all the jobs that run on a pull request, use the following command: + +% act pull_request +% act workflow_call -j soundness --input shell_check_enabled=true + +To bind-mount the working directory to the container, rather than a copy, use `--bind`. For example, to run just the formatting, and have the results reflected in your working directory: + +% act --bind workflow_call -j soundness --input format_check_enabled=true + +If you’d like `act` to always run with certain flags, these can be be placed in an `.actrc` file either in the current working directory or your home directory, for example: + +--container-architecture=linux/amd64 +--remote-name upstream +--action-offline-mode + +#### Code generation with gyb + +This package uses the “generate your boilerplate” (gyb) script from the Swift repository to stamp out repetitive code for each supported primitive type. + +The files that include gyb syntax end with `.gyb`, and after making changes to any of those files, run: + +./Scripts/generate_boilerplate_files_with_gyb.sh + +If you’re adding a new `.gyb` file, also make sure to add it to the exclude list in `Package.swift`. + +After running this script, also run the formatter before opening a PR. + +#### Code formatting + +The project uses swift-format for consistent code style. You can run CI checks locally using `act`. + +To run formatting checks: + +act --bind workflow_call -j soundness --input format_check_enabled=true + +#### Testing + +The package includes comprehensive test suites for all components: + +- Unit tests for individual providers and utilities. + +- Compatibility tests using `ProviderCompatTest` for built-in providers. + +Run tests using Swift Package Manager: + +swift test --enable-all-traits + +#### Documentation + +Documentation is written using DocC and includes: + +- API reference documentation in source code. + +- Conceptual guides in `.docc` catalogs. + +- Usage examples and best practices. + +- Troubleshooting guides. + +Preview documentation locally: + +SWIFT_PREVIEW_DOCS=1 swift package --disable-sandbox preview-documentation --target Configuration + +#### Code style + +- Follow Swift API Design Guidelines. + +- Use meaningful names for types, methods, and variables. + +- Include comprehensive documentation for all APIs, not only public types. + +- Write unit tests for new functionality. + +#### Provider development + +When developing new configuration providers: + +1. Implement the `ConfigProvider` protocol. + +2. Add comprehensive unit tests. + +3. Run compatibility tests using `ProviderCompatTest`. + +4. Add documentation to all symbols, not just `public`. + +#### Documentation requirements + +All APIs must include: + +- Clear, concise documentation comments. + +- Usage examples where appropriate. + +- Parameter and return value descriptions. + +- Error conditions and handling. + +## See Also + +### Contributing + +Collaborate on API changes to Swift Configuration by writing a proposal. + +- Developing Swift Configuration +- Overview +- Process +- Repository structure +- Development tools +- Contributing guidelines +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/troubleshooting + +- Configuration +- Troubleshooting and access reporting + +Article + +# Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +## Overview + +### Debugging configuration issues + +If your configuration values aren’t being read correctly, check: + +1. **Environment variable naming**: When using `EnvironmentVariablesProvider`, keys are automatically converted to uppercase with dots replaced by underscores. For example, `database.url` becomes `DATABASE_URL`. + +2. **Provider ordering**: When using multiple providers, they’re checked in order and the first one that returns a value wins. + +3. **Debug with an access reporter**: Use access reporting to see which keys are being queried and what values (if any) are being returned. See the next section for details. + +For guidance on selecting the right configuration access patterns and reader methods, check out Choosing the access pattern and Choosing reader methods. + +### Access reporting + +Configuration access reporting can help you debug issues and understand which configuration values your application is using. Swift Configuration provides two built-in ways to log access ( `AccessLogger` and `FileAccessLogger`), and you can also implement your own `AccessReporter`. + +#### Using AccessLogger + +`AccessLogger` integrates with Swift Log and records all configuration accesses: + +let logger = Logger(label: "...") +let accessLogger = AccessLogger(logger: logger) +let config = ConfigReader(provider: provider, accessReporter: accessLogger) + +// Each access will now be logged. +let timeout = config.double(forKey: "http.timeout", default: 30.0) + +This produces log entries showing: + +- Which configuration keys were accessed. + +- What values were returned (with secret values redacted). + +- Which provider supplied the value. + +- Whether default values were used. + +- The location of the code reading the config value. + +- The timestamp of the access. + +#### Using FileAccessLogger + +For writing access events to a file, especially useful during ad-hoc debugging, use `FileAccessLogger`: + +let fileLogger = try FileAccessLogger(filePath: "/var/log/myapp/config-access.log") +let config = ConfigReader(provider: provider, accessReporter: fileLogger) + +You can also enable file access logging for the whole application, without recompiling your code, by setting an environment variable: + +export CONFIG_ACCESS_LOG_FILE=/var/log/myapp/config-access.log + +And then read from the file to see one line per config access: + +tail -f /var/log/myapp/config-access.log + +#### Provider errors + +If any provider throws an error during lookup: + +- **Required methods** (`requiredString`, etc.): Error is immediately thrown to the caller. + +- **Optional methods** (with or without defaults): Error is handled gracefully; `nil` or the default value is returned. + +#### Missing values + +When no provider has the requested value: + +- **Methods with defaults**: Return the provided default value. + +- **Methods without defaults**: Return `nil`. + +- **Required methods**: Throw an error. + +#### File not found errors + +File-based providers ( `FileProvider`, `ReloadingFileProvider`, `DirectoryFilesProvider`, `EnvironmentVariablesProvider` with file path) can throw “file not found” errors when expected configuration files don’t exist. + +Common scenarios and solutions: + +**Optional configuration files:** + +// Problem: App crashes when optional config file is missing + +// Solution: Use allowMissing parameter + +filePath: "/etc/optional-config.json", +allowMissing: true +) + +**Environment-specific files:** + +// Different environments may have different config files +let configPath = "/etc/\(environment)/config.json" + +filePath: configPath, +allowMissing: true // Gracefully handle missing env-specific configs +) + +**Container startup issues:** + +// Config files might not be ready when container starts + +filePath: "/mnt/config/app.json", +allowMissing: true // Allow startup with empty config, load when available +) + +#### Configuration not updating + +If your reloading provider isn’t detecting file changes: + +1. **Check ServiceGroup**: Ensure the provider is running in a `ServiceGroup`. + +2. **Enable verbose logging**: The built-in providers use Swift Log for detailed logging, which can help spot issues. + +3. **Verify file path**: Confirm the file path is correct, the file exists, and file permissions are correct. + +4. **Check poll interval**: Consider if your poll interval is appropriate for your use case. + +#### ServiceGroup integration issues + +Common ServiceGroup problems: + +// Incorrect: Provider not included in ServiceGroup + +let config = ConfigReader(provider: provider) +// File monitoring won't work + +// Correct: Provider runs in ServiceGroup + +let serviceGroup = ServiceGroup(services: [provider], logger: logger) +try await serviceGroup.run() + +For more details about reloading providers and ServiceLifecycle integration, see Using reloading providers. To learn about proper configuration practices that can prevent common issues, check out Adopting best practices. + +## See Also + +### Troubleshooting and access reporting + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +- Troubleshooting and access reporting +- Overview +- Debugging configuration issues +- Access reporting +- Error handling +- Reloading provider troubleshooting +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileparsingoptions + +- Configuration +- FileParsingOptions + +Protocol + +# FileParsingOptions + +A type that provides parsing options for file configuration snapshots. + +protocol FileParsingOptions : Sendable + +FileProviderSnapshot.swift + +## Overview + +This protocol defines the requirements for parsing options types used with `FileConfigSnapshot` implementations. Types conforming to this protocol provide configuration parameters that control how file data is interpreted and parsed during snapshot creation. + +The parsing options are passed to the `init(data:providerName:parsingOptions:)` initializer, allowing custom file format implementations to access format-specific parsing settings such as character encoding, date formats, or validation rules. + +## Usage + +Implement this protocol to provide parsing options for your custom `FileConfigSnapshot`: + +struct MyParsingOptions: FileParsingOptions { +let encoding: String.Encoding +let dateFormat: String? +let strictValidation: Bool + +static let `default` = MyParsingOptions( +encoding: .utf8, +dateFormat: nil, +strictValidation: false +) +} + +struct MyFormatSnapshot: FileConfigSnapshot { +typealias ParsingOptions = MyParsingOptions + +init(data: RawSpan, providerName: String, parsingOptions: ParsingOptions) throws { +// Implementation that inspects `parsingOptions` properties like `encoding`, +// `dateFormat`, and `strictValidation`. +} +} + +## Topics + +### Required properties + +``static var `default`: Self`` + +The default instance of this options type. + +**Required** + +### Parsing options + +`protocol FileConfigSnapshot` + +A protocol for configuration snapshots created from file data. + +## Relationships + +### Inherits From + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Conforming Types + +- `JSONSnapshot.ParsingOptions` +- `YAMLSnapshot.ParsingOptions` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- FileParsingOptions +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshot + +- Configuration +- ConfigSnapshot + +Protocol + +# ConfigSnapshot + +An immutable snapshot of a configuration provider’s state. + +protocol ConfigSnapshot : Sendable + +ConfigProvider.swift + +## Overview + +Snapshots enable consistent reads of multiple related configuration keys by capturing the provider’s state at a specific moment. This prevents the underlying data from changing between individual key lookups. + +## Topics + +### Required methods + +`var providerName: String` + +The human-readable name of the configuration provider that created this snapshot. + +**Required** + +Returns a value for the specified key from this immutable snapshot. + +## Relationships + +### Inherits From + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Inherited By + +- `FileConfigSnapshot` + +### Conforming Types + +- `JSONSnapshot` +- `YAMLSnapshot` + +## See Also + +### Creating a custom provider + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- ConfigSnapshot +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configuring-applications + +- Configuration +- Configuring applications + +Article + +# Configuring applications + +Provide flexible and consistent configuration for your application. + +## Overview + +Swift Configuration provides consistent configuration for your tools and applications. This guide shows how to: + +1. Set up a configuration hierarchy with multiple providers. + +2. Configure your application’s components. + +3. Access configuration values in your application and libraries. + +4. Monitor configuration access with access reporting. + +This pattern works well for server applications where configuration comes from environment variables, configuration files, and remote services. + +### Setting up a configuration hierarchy + +Start by creating a configuration hierarchy in your application’s entry point. This defines the order in which configuration sources are consulted when looking for values: + +import Configuration +import Logging + +// Create a logger. +let logger: Logger = ... + +// Set up the configuration hierarchy: +// - environment variables first, +// - then JSON file, +// - then in-memory defaults. +// Also emit log accesses into the provider logger, +// with secrets automatically redacted. + +let config = ConfigReader( +providers: [\ +EnvironmentVariablesProvider(),\ + +filePath: "/etc/myapp/config.json",\ +allowMissing: true // Optional: treat missing file as empty config\ +),\ +InMemoryProvider(values: [\ +"http.server.port": 8080,\ +"http.server.host": "127.0.0.1",\ +"http.client.timeout": 30.0\ +])\ +], +accessReporter: AccessLogger(logger: logger) +) + +// Start your application with the config. +try await runApplication(config: config, logger: logger) + +This configuration hierarchy gives priority to environment variables, then falls + +Next, configure your application using the configuration reader: + +func runApplication( +config: ConfigReader, +logger: Logger +) async throws { +// Get server configuration. +let serverHost = config.string( +forKey: "http.server.host", +default: "localhost" +) +let serverPort = config.int( +forKey: "http.server.port", +default: 8080 +) + +// Read library configuration with a scoped reader +// with the prefix `http.client`. +let httpClientConfig = HTTPClientConfiguration( +config: config.scoped(to: "http.client") +) +let httpClient = HTTPClient(configuration: httpClientConfig) + +// Run your server with the configured components +try await startHTTPServer( +host: serverHost, +port: serverPort, +httpClient: httpClient, +logger: logger +) +} + +Finally, you configure your application across the three sources. A fully configured set of environment variables could look like the following: + +export HTTP_SERVER_HOST=localhost +export HTTP_SERVER_PORT=8080 +export HTTP_CLIENT_TIMEOUT=30.0 +export HTTP_CLIENT_MAX_CONCURRENT_CONNECTIONS=20 +export HTTP_CLIENT_BASE_URL="https://example.com" +export HTTP_CLIENT_DEBUG_LOGGING=true + +In JSON: + +{ +"http": { +"server": { +"host": "localhost", +"port": 8080 +}, +"client": { +"timeout": 30.0, +"maxConcurrentConnections": 20, +"baseURL": "https://example.com", +"debugLogging": true +} +} +} + +And using `InMemoryProvider`: + +[\ +"http.server.port": 8080,\ +"http.server.host": "127.0.0.1",\ +"http.client.timeout": 30.0,\ +"http.client.maxConcurrentConnections": 20,\ +"http.client.baseURL": "https://example.com",\ +"http.client.debugLogging": true,\ +] + +In practice, you’d only specify a subset of the config keys in each location, to match the needs of your service’s operators. + +### Using scoped configuration + +For services with multiple instances of the same component, but with different settings, use scoped configuration: + +// For our server example, we might have different API clients +// that need different settings: + +let adminConfig = config.scoped(to: "services.admin") +let customerConfig = config.scoped(to: "services.customer") + +// Using the admin API configuration +let adminBaseURL = adminConfig.string( +forKey: "baseURL", +default: "https://admin-api.example.com" +) +let adminTimeout = adminConfig.double( +forKey: "timeout", +default: 60.0 +) + +// Using the customer API configuration +let customerBaseURL = customerConfig.string( +forKey: "baseURL", +default: "https://customer-api.example.com" +) +let customerTimeout = customerConfig.double( +forKey: "timeout", +default: 30.0 +) + +This can be configured via environment variables as follows: + +# Admin API configuration +export SERVICES_ADMIN_BASE_URL="https://admin.internal-api.example.com" +export SERVICES_ADMIN_TIMEOUT=120.0 +export SERVICES_ADMIN_DEBUG_LOGGING=true + +# Customer API configuration +export SERVICES_CUSTOMER_BASE_URL="https://api.example.com" +export SERVICES_CUSTOMER_MAX_CONCURRENT_CONNECTIONS=20 +export SERVICES_CUSTOMER_TIMEOUT=15.0 + +For details about the key conversion logic, check out `EnvironmentVariablesProvider`. + +For more configuration guidance, see Adopting best practices. To understand different access patterns and reader methods, refer to Choosing the access pattern and Choosing reader methods. For handling secrets securely, check out Handling secrets correctly. + +## See Also + +### Essentials + +Configuring libraries + +Provide a consistent and flexible way to configure your library. + +Example use cases + +Review common use cases with ready-to-copy code samples. + +Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +- Configuring applications +- Overview +- Setting up a configuration hierarchy +- Configure your application +- Using scoped configuration +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/systempackage + +- Configuration +- SystemPackage + +Extended Module + +# SystemPackage + +## Topics + +### Extended Structures + +`extension FilePath` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence + +- Configuration +- ConfigUpdatesAsyncSequence + +Structure + +# ConfigUpdatesAsyncSequence + +A concrete async sequence for delivering updated configuration values. + +AsyncSequences.swift + +## Topics + +### Creating an asynchronous update sequence + +Creates a new concrete async sequence wrapping the provided existential sequence. + +## Relationships + +### Conforms To + +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` +- `_Concurrency.AsyncSequence` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +- ConfigUpdatesAsyncSequence +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/expressiblebyconfigstring + +- Configuration +- ExpressibleByConfigString + +Protocol + +# ExpressibleByConfigString + +A protocol for types that can be initialized from configuration string values. + +protocol ExpressibleByConfigString : CustomStringConvertible + +ExpressibleByConfigString.swift + +## Mentioned in + +Choosing reader methods + +## Overview + +Conform your custom types to this protocol to enable automatic conversion when using the `as:` parameter with configuration reader methods such as `string(forKey:as:isSecret:fileID:line:)`. + +## Custom types + +For other custom types, conform to the protocol `ExpressibleByConfigString` by providing a failable initializer and the `description` property: + +struct DatabaseURL: ExpressibleByConfigString { +let url: URL + +init?(configString: String) { +guard let url = URL(string: configString) else { return nil } +self.url = url +} + +var description: String { url.absoluteString } +} + +// Now you can use it with automatic conversion +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let dbUrl = config.string(forKey: "database.url", as: DatabaseURL.self) + +## Built-in conformances + +The following Foundation types already conform to `ExpressibleByConfigString`: + +- `SystemPackage.FilePath` \- Converts from file paths. + +- `Foundation.URL` \- Converts from URL strings. + +- `Foundation.UUID` \- Converts from UUID strings. + +- `Foundation.Date` \- Converts from ISO8601 date strings. + +## Topics + +### Required methods + +`init?(configString: String)` + +Creates an instance from a configuration string value. + +**Required** + +## Relationships + +### Inherits From + +- `Swift.CustomStringConvertible` + +### Conforming Types + +- `Date` +- `FilePath` +- `URL` +- `UUID` + +## See Also + +### Value conversion + +`protocol ConfigBytesFromStringDecoder` + +A protocol for decoding string configuration values into byte arrays. + +`struct ConfigBytesFromBase64StringDecoder` + +A decoder that converts base64-encoded strings into byte arrays. + +`struct ConfigBytesFromHexStringDecoder` + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +- ExpressibleByConfigString +- Mentioned in +- Overview +- Custom types +- Built-in conformances +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/using-in-memory-providers + +- Configuration +- Using in-memory providers + +Article + +# Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +## Overview + +Swift Configuration provides two in-memory providers, which are directly instantiated with the desired keys and values, rather than being parsed from another representation. These providers are particularly useful for testing, providing fallback values, and bridging with other configuration systems. + +- `InMemoryProvider` is an immutable value type, and can be useful for defining overrides and fallbacks in a provider hierarchy. + +- `MutableInMemoryProvider` is a mutable reference type, allowing you to update values and get any watchers notified automatically. It can be used to bridge from other stateful, callback-based configuration sources. + +### InMemoryProvider + +The `InMemoryProvider` is ideal for static configuration values that don’t change during application runtime. + +#### Basic usage + +Create an `InMemoryProvider` with a dictionary of configuration values: + +let provider = InMemoryProvider(values: [\ +"database.host": "localhost",\ +"database.port": 5432,\ +"api.timeout": 30.0,\ +"debug.enabled": true\ +]) + +let config = ConfigReader(provider: provider) +let host = config.string(forKey: "database.host") // "localhost" +let port = config.int(forKey: "database.port") // 5432 + +#### Using with hierarchical keys + +You can use `AbsoluteConfigKey` for more complex key structures: + +let provider = InMemoryProvider(values: [\ +AbsoluteConfigKey(["http", "client", "timeout"]): 30.0,\ +AbsoluteConfigKey(["http", "server", "port"]): 8080,\ +AbsoluteConfigKey(["logging", "level"]): "info"\ +]) + +#### Configuration context + +The in-memory provider performs exact matching of config keys, including the context. This allows you to provide different values for the same key path based on contextual information. + +The following example shows using two keys with the same key path, but different context, and giving them two different values: + +let provider = InMemoryProvider( +values: [\ +AbsoluteConfigKey(\ +["http", "client", "timeout"],\ +context: ["upstream": "example1.org"]\ +): 15.0,\ +AbsoluteConfigKey(\ +["http", "client", "timeout"],\ +context: ["upstream": "example2.org"]\ +): 30.0,\ +] +) + +With a provider configured this way, a config reader will return the following results: + +let config = ConfigReader(provider: provider) +config.double(forKey: "http.client.timeout") // nil +config.double( +forKey: ConfigKey( +"http.client.timeout", +context: ["upstream": "example1.org"] +) +) // 15.0 +config.double( +forKey: ConfigKey( +"http.client.timeout", +context: ["upstream": "example2.org"] +) +) // 30.0 + +### MutableInMemoryProvider + +The `MutableInMemoryProvider` allows you to modify configuration values at runtime and notify watchers of changes. + +#### Basic usage + +let provider = MutableInMemoryProvider() +provider.setValue("localhost", forKey: "database.host") +provider.setValue(5432, forKey: "database.port") + +let config = ConfigReader(provider: provider) +let host = config.string(forKey: "database.host") // "localhost" + +#### Updating values + +You can update values after creation, and any watchers will be notified: + +// Initial setup +provider.setValue("debug", forKey: "logging.level") + +// Later in your application, watchers are notified +provider.setValue("info", forKey: "logging.level") + +#### Watching for changes + +Use the provider’s async sequence to watch for configuration changes: + +let config = ConfigReader(provider: provider) +try await config.watchString( +forKey: "logging.level", +as: Logger.Level.self, +default: .debug +) { updates in +for try await level in updates { +print("Logging level changed to: \(level)") +} +} + +#### Testing + +In-memory providers are excellent for unit testing: + +func testDatabaseConnection() { +let testProvider = InMemoryProvider(values: [\ +"database.host": "test-db.example.com",\ +"database.port": 5433,\ +"database.name": "test_db"\ +]) + +let config = ConfigReader(provider: testProvider) +let connection = DatabaseConnection(config: config) +// Test your database connection logic +} + +#### Fallback values + +Use `InMemoryProvider` as a fallback in a provider hierarchy: + +let fallbackProvider = InMemoryProvider(values: [\ +"api.timeout": 30.0,\ +"retry.maxAttempts": 3,\ +"cache.enabled": true\ +]) + +let config = ConfigReader(providers: [\ +EnvironmentVariablesProvider(),\ +fallbackProvider\ +// Used when environment variables are not set\ +]) + +#### Bridging other systems + +Use `MutableInMemoryProvider` to bridge configuration from other systems: + +class ConfigurationBridge { +private let provider = MutableInMemoryProvider() + +func updateFromExternalSystem(_ values: [String: ConfigValue]) { +for (key, value) in values { +provider.setValue(value, forKey: key) +} +} +} + +For comparison with reloading providers, see Using reloading providers. To understand different access patterns and when to use each provider type, check out Choosing the access pattern. For more configuration guidance, refer to Adopting best practices. + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- Using in-memory providers +- Overview +- InMemoryProvider +- MutableInMemoryProvider +- Common Use Cases +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/snapshot() + +#app-main) + +- Configuration +- ConfigReader +- snapshot() + +Instance Method + +# snapshot() + +Returns a snapshot of the current configuration state. + +ConfigSnapshotReader.swift + +## Return Value + +The snapshot. + +## Discussion + +The snapshot reader provides read-only access to the configuration’s state at the time the method was called. + +let snapshot = config.snapshot() +// Use snapshot to read config values +let cert = snapshot.string(forKey: "cert") +let privateKey = snapshot.string(forKey: "privateKey") +// Ensures that both values are coming from the same underlying snapshot and that a provider +// didn't change its internal state between the two `string(...)` calls. +let identity = MyIdentity(cert: cert, privateKey: privateKey) + +## See Also + +### Reading from a snapshot + +Watches the configuration for changes. + +- snapshot() +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/best-practices + +- Configuration +- Adopting best practices + +Article + +# Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +## Overview + +When designing configuration for Swift libraries and applications, follow these patterns to create consistent, maintainable code that integrates well with the Swift ecosystem. + +### Document configuration keys + +Include thorough documentation about what configuration keys your library reads. For each key, document: + +- The key name and its hierarchical structure. + +- The expected data type. + +- Whether the key is required or optional. + +- Default values when applicable. + +- Valid value ranges or constraints. + +- Usage examples. + +public struct HTTPClientConfiguration { +/// ... +/// +/// ## Configuration keys: +/// - `timeout` (double, optional, default: 30.0): Request timeout in seconds. +/// - `maxRetries` (int, optional, default: 3, range: 0-10): Maximum retry attempts. +/// - `baseURL` (string, required): Base URL for requests. +/// - `apiKey` (string, required, secret): API authentication key. +/// +/// ... +public init(config: ConfigReader) { +// Implementation... +} +} + +### Use sensible defaults + +Provide reasonable default values to make your library work without extensive configuration. + +// Good: Provides sensible defaults +let timeout = config.double(forKey: "http.timeout", default: 30.0) +let maxConnections = config.int(forKey: "http.maxConnections", default: 10) + +// Avoid: Requiring configuration for common scenarios +let timeout = try config.requiredDouble(forKey: "http.timeout") // Forces users to configure + +### Use scoped configuration + +Organize your configuration keys logically using namespaces to keep related keys together. + +// Good: +let httpConfig = config.scoped(to: "http") +let timeout = httpConfig.double(forKey: "timeout", default: 30.0) +let retries = httpConfig.int(forKey: "retries", default: 3) + +// Better (in libraries): Offer a convenience method that reads your library's configuration. +// Tip: Read the configuration values from the provided reader directly, do not scope it +// to a "myLibrary" namespace. Instead, let the caller of MyLibraryConfiguration.init(config:) +// perform any scoping for your library's configuration. +public struct MyLibraryConfiguration { +public init(config: ConfigReader) { +self.timeout = config.double(forKey: "timeout", default: 30.0) +self.retries = config.int(forKey: "retries", default: 3) +} +} + +// Called from an app - the caller is responsible for adding a namespace and naming it, if desired. +let libraryConfig = MyLibraryConfiguration(config: config.scoped(to: "myLib")) + +### Mark secrets appropriately + +Mark sensitive configuration values like API keys, passwords, or tokens as secrets using the `isSecret: true` parameter. This tells access reporters to redact those values in logs. + +// Mark sensitive values as secrets +let apiKey = try config.requiredString(forKey: "api.key", isSecret: true) +let password = config.string(forKey: "database.password", default: nil, isSecret: true) + +// Regular values don't need the isSecret parameter +let timeout = config.double(forKey: "api.timeout", default: 30.0) + +Some providers also support the `SecretsSpecifier`, allowing you to mark which values are secret during application bootstrapping. + +For comprehensive guidance on handling secrets securely, see Handling secrets correctly. + +### Prefer optional over required + +Only mark configuration as required if your library absolutely cannot function without it. For most cases, provide sensible defaults and make configuration optional. + +// Good: Optional with sensible defaults +let timeout = config.double(forKey: "timeout", default: 30.0) +let debug = config.bool(forKey: "debug", default: false) + +// Use required only when absolutely necessary +let apiEndpoint = try config.requiredString(forKey: "api.endpoint") + +For more details, check out Choosing reader methods. + +### Validate configuration values + +Validate configuration values and throw meaningful errors for invalid input to catch configuration issues early. + +public init(config: ConfigReader) throws { +let timeout = config.double(forKey: "timeout", default: 30.0) + +throw MyConfigurationError.invalidTimeout("Timeout must be positive, got: \(timeout)") +} + +let maxRetries = config.int(forKey: "maxRetries", default: 3) + +throw MyConfigurationError.invalidRetryCount("Max retries must be 0-10, got: \(maxRetries)") +} + +self.timeout = timeout +self.maxRetries = maxRetries +} + +#### When to use reloading providers + +Use reloading providers when you need configuration changes to take effect without restarting your application: + +- Long-running services that can’t be restarted frequently. + +- Development environments where you iterate on configuration. + +- Applications that receive configuration updates through file deployments. + +Check out Using reloading providers to learn more. + +#### When to use static providers + +Use static providers when configuration doesn’t change during runtime: + +- Containerized applications with immutable configuration. + +- Applications where configuration is set once at startup. + +For help choosing between different access patterns and reader method variants, see Choosing the access pattern and Choosing reader methods. For troubleshooting configuration issues, refer to Troubleshooting and access reporting. + +## See Also + +### Essentials + +Configuring applications + +Provide flexible and consistent configuration for your application. + +Configuring libraries + +Provide a consistent and flexible way to configure your library. + +Example use cases + +Review common use cases with ready-to-copy code samples. + +- Adopting best practices +- Overview +- Document configuration keys +- Use sensible defaults +- Use scoped configuration +- Mark secrets appropriately +- Prefer optional over required +- Validate configuration values +- Choosing provider types +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier + +- Configuration +- SecretsSpecifier + +Enumeration + +# SecretsSpecifier + +A specification for identifying which configuration values contain sensitive information. + +SecretsSpecifier.swift + +## Mentioned in + +Adopting best practices + +Handling secrets correctly + +## Overview + +Configuration providers use secrets specifiers to determine which values should be marked as sensitive and protected from accidental disclosure in logs, debug output, or access reports. Secret values are handled specially by `AccessReporter` instances and other components that process configuration data. + +## Usage patterns + +### Mark all values as secret + +Use this for providers that exclusively handle sensitive data: + +let provider = InMemoryProvider( +values: ["api.key": "secret123", "db.password": "pass456"], +secretsSpecifier: .all +) + +### Mark specific keys as secret + +Use this when you know which specific keys contain sensitive information: + +let provider = EnvironmentVariablesProvider( +secretsSpecifier: .specific( +["API_KEY", "DATABASE_PASSWORD", "JWT_SECRET"] +) +) + +### Dynamic secret detection + +Use this for complex logic that determines secrecy based on key patterns or values: + +filePath: "/etc/config.json", +secretsSpecifier: .dynamic { key, value in +// Mark keys containing "password", +// "secret", or "token" as secret +key.lowercased().contains("password") || +key.lowercased().contains("secret") || +key.lowercased().contains("token") +} +) + +### No secret values + +Use this for providers that handle only non-sensitive configuration: + +let provider = InMemoryProvider( +values: ["app.name": "MyApp", "log.level": "info"], +secretsSpecifier: .none +) + +## Topics + +### Types of specifiers + +`case all` + +The library treats all configuration values as secrets. + +The library treats the specified keys as secrets. + +The library determines the secret status dynamically by evaluating each key-value pair. + +`case none` + +The library treats no configuration values as secrets. + +### Inspecting a secrets specifier + +Determines whether a configuration value should be treated as secret. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- SecretsSpecifier +- Mentioned in +- Overview +- Usage patterns +- Mark all values as secret +- Mark specific keys as secret +- Dynamic secret detection +- No secret values +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider + +- Configuration +- DirectoryFilesProvider + +Structure + +# DirectoryFilesProvider + +A configuration provider that reads values from individual files in a directory. + +struct DirectoryFilesProvider + +DirectoryFilesProvider.swift + +## Mentioned in + +Example use cases + +Handling secrets correctly + +Troubleshooting and access reporting + +## Overview + +This provider reads configuration values from a directory where each file represents a single configuration key-value pair. The file name becomes the configuration key, and the file contents become the value. This approach is commonly used by secret management systems that mount secrets as individual files. + +## Key mapping + +Configuration keys are transformed into file names using these rules: + +- Components are joined with dashes. + +- Non-alphanumeric characters (except dashes) are replaced with underscores. + +For example: + +## Value handling + +The provider reads file contents as UTF-8 strings and converts them to the requested type. For binary data (bytes type), it reads raw file contents directly without string conversion. Leading and trailing whitespace is always trimmed from string values. + +## Supported data types + +The provider supports all standard configuration types: + +- Strings (UTF-8 text files) + +- Integers, doubles, and booleans (parsed from string contents) + +- Arrays (using configurable separator, comma by default) + +- Byte arrays (raw file contents) + +## Secret handling + +By default, all values are marked as secrets for security. This is appropriate since this provider is typically used for sensitive data mounted by secret management systems. + +## Usage + +### Reading from a secrets directory + +// Assuming /run/secrets contains files: +// - database-password (contains: "secretpass123") +// - max-connections (contains: "100") +// - enable-cache (contains: "true") + +let provider = try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) + +let config = ConfigReader(provider: provider) +let dbPassword = config.string(forKey: "database.password") // "secretpass123" +let maxConn = config.int(forKey: "max.connections", default: 50) // 100 +let cacheEnabled = config.bool(forKey: "enable.cache", default: false) // true + +### Reading binary data + +// For binary files like certificates or keys +let provider = try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) + +let config = ConfigReader(provider: provider) +let certData = try config.requiredBytes(forKey: "tls.cert") // Raw file bytes + +### Custom array handling + +// If files contain comma-separated lists +let provider = try await DirectoryFilesProvider( +directoryPath: "/etc/config" +) + +// File "allowed-hosts" contains: "host1.example.com,host2.example.com,host3.example.com" +let hosts = config.stringArray(forKey: "allowed.hosts", default: []) +// ["host1.example.com", "host2.example.com", "host3.example.com"] + +## Configuration context + +This provider ignores the context passed in `context`. All keys are resolved using only their component path. + +## Topics + +### Creating a directory files provider + +Creates a new provider that reads files from a directory. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +Using in-memory providers + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`struct InMemoryProvider` + +A configuration provider that stores values in memory. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- DirectoryFilesProvider +- Mentioned in +- Overview +- Key mapping +- Value handling +- Supported data types +- Secret handling +- Usage +- Reading from a secrets directory +- Reading binary data +- Custom array handling +- Configuration context +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey + +- Configuration +- AbsoluteConfigKey + +Structure + +# AbsoluteConfigKey + +A configuration key that represents an absolute path to a configuration value. + +struct AbsoluteConfigKey + +ConfigKey.swift + +## Mentioned in + +Using in-memory providers + +## Overview + +Absolute configuration keys are similar to relative keys but represent complete paths from the root of the configuration hierarchy. They are used internally by the configuration system after resolving any key prefixes or scoping. + +Like relative keys, absolute keys consist of hierarchical components and optional context information. + +## Topics + +### Creating an absolute configuration key + +`init(ConfigKey)` + +Creates a new absolute configuration key from a relative key. + +[`init([String], context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/init(_:context:)) + +Creates a new absolute configuration key. + +### Inspecting an absolute configuration key + +[`var components: [String]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/components) + +The hierarchical components that make up this absolute configuration key. + +[`var context: [String : ConfigContextValue]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/context) + +Additional context information for this configuration key. + +### Instance Methods + +Returns a new absolute configuration key by appending the given relative key. + +Returns a new absolute configuration key by prepending the given relative key. + +## Relationships + +### Conforms To + +- `Swift.Comparable` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` +- `Swift.Equatable` +- `Swift.ExpressibleByArrayLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Configuration keys + +`struct ConfigKey` + +A configuration key representing a relative path to a configuration value. + +`enum ConfigContextValue` + +A value that can be stored in a configuration context. + +- AbsoluteConfigKey +- Mentioned in +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromhexstringdecoder + +- Configuration +- ConfigBytesFromHexStringDecoder + +Structure + +# ConfigBytesFromHexStringDecoder + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +struct ConfigBytesFromHexStringDecoder + +ConfigBytesFromStringDecoder.swift + +## Overview + +This decoder interprets string configuration values as hexadecimal-encoded data and converts them to their binary representation. It expects strings to contain only valid hexadecimal characters (0-9, A-F, a-f). + +## Hexadecimal format + +The decoder expects strings with an even number of characters, where each pair of characters represents one byte. For example, “48656C6C6F” represents the bytes for “Hello”. + +## Topics + +### Creating bytes from a hex string decoder + +`init()` + +Creates a new hexadecimal decoder. + +## Relationships + +### Conforms To + +- `ConfigBytesFromStringDecoder` +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Value conversion + +`protocol ExpressibleByConfigString` + +A protocol for types that can be initialized from configuration string values. + +`protocol ConfigBytesFromStringDecoder` + +A protocol for decoding string configuration values into byte arrays. + +`struct ConfigBytesFromBase64StringDecoder` + +A decoder that converts base64-encoded strings into byte arrays. + +- ConfigBytesFromHexStringDecoder +- Overview +- Hexadecimal format +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent + +- Configuration +- ConfigContent + +Enumeration + +# ConfigContent + +The raw content of a configuration value. + +@frozen +enum ConfigContent + +ConfigProvider.swift + +## Topics + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +## Relationships + +### Conforms To + +- `Swift.Copyable` +- `Swift.Equatable` +- `Swift.ExpressibleByBooleanLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByFloatLiteral` +- `Swift.ExpressibleByIntegerLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- ConfigContent +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue + +- Configuration +- ConfigValue + +Structure + +# ConfigValue + +A configuration value that wraps content with metadata. + +struct ConfigValue + +ConfigProvider.swift + +## Mentioned in + +Handling secrets correctly + +## Overview + +Configuration values pair raw content with a flag indicating whether the value contains sensitive information. Secret values are protected from accidental disclosure in logs and debug output: + +let apiKey = ConfigValue(.string("sk-abc123"), isSecret: true) + +## Topics + +### Creating a config value + +`init(ConfigContent, isSecret: Bool)` + +Creates a new configuration value. + +### Inspecting a config value + +`var content: ConfigContent` + +The configuration content. + +`var isSecret: Bool` + +Whether this value contains sensitive information that should not be logged. + +## Relationships + +### Conforms To + +- `Swift.Copyable` +- `Swift.CustomStringConvertible` +- `Swift.Equatable` +- `Swift.ExpressibleByBooleanLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByFloatLiteral` +- `Swift.ExpressibleByIntegerLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`enum ConfigType` + +The supported configuration value types. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- ConfigValue +- Mentioned in +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/foundation + +- Configuration +- Foundation + +Extended Module + +# Foundation + +## Topics + +### Extended Structures + +`extension Date` + +`extension URL` + +`extension UUID` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader + +- Configuration +- ConfigSnapshotReader + +Structure + +# ConfigSnapshotReader + +A container type for reading config values from snapshots. + +struct ConfigSnapshotReader + +ConfigSnapshotReader.swift + +## Overview + +A config snapshot reader provides read-only access to config values stored in an underlying `ConfigSnapshot`. Unlike a config reader, which can access live, changing config values from providers, a snapshot reader works with a fixed, immutable snapshot of the configuration data. + +## Usage + +Get a snapshot reader from a config reader by using the `snapshot()` method. All values in the snapshot are guaranteed to be from the same point in time: + +// Get a snapshot from a ConfigReader +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let snapshot = config.snapshot() +// Use snapshot to read config values +let cert = snapshot.string(forKey: "cert") +let privateKey = snapshot.string(forKey: "privateKey") +// Ensures that both values are coming from the same +// underlying snapshot and that a provider didn't change +// its internal state between the two `string(...)` calls. +let identity = MyIdentity(cert: cert, privateKey: privateKey) + +Or you can watch for snapshot updates using the `watchSnapshot(fileID:line:updatesHandler:)` method: + +try await config.watchSnapshot { snapshots in +for await snapshot in snapshots { +// Process each new configuration snapshot +let cert = snapshot.string(forKey: "cert") +let privateKey = snapshot.string(forKey: "privateKey") +// Ensures that both values are coming from the same +// underlying snapshot and that a provider didn't change +// its internal state between the two `string(...)` calls. +let newCert = MyCert(cert: cert, privateKey: privateKey) +print("Certificate was updated: \(newCert.redactedDescription)") +} +} + +### Scoping + +Like `ConfigReader`, you can set a key prefix on the config snapshot reader, allowing all config lookups to prepend a prefix to the keys, which lets you pass a scoped snapshot reader to nested components. + +let httpConfig = snapshotReader.scoped(to: "http") +let timeout = httpConfig.int(forKey: "timeout") +// Reads from "http.timeout" in the snapshot + +### Config keys and context + +The library requests config values using a canonical “config key”, that represents a key path. You can provide additional context that was used by some providers when the snapshot was created. + +let httpTimeout = snapshotReader.int( +forKey: ConfigKey("http.timeout", context: ["upstream": "example.com"]), +default: 60 +) + +### Automatic type conversion + +String configuration values can be automatically converted to other types using the `as:` parameter. This works with: + +- Types that you explicitly conform to `ExpressibleByConfigString`. + +- Built-in types that already conform to `ExpressibleByConfigString`: + +- `SystemPackage.FilePath` \- Converts from file paths. + +- `Foundation.URL` \- Converts from URL strings. + +- `Foundation.UUID` \- Converts from UUID strings. + +- `Foundation.Date` \- Converts from ISO8601 date strings. + +// Built-in type conversion +let apiUrl = snapshot.string( +forKey: "api.url", +as: URL.self +) +let requestId = snapshot.string( +forKey: "request.id", +as: UUID.self +) + +enum LogLevel: String { +case debug, info, warning, error +} +let logLevel = snapshot.string( +forKey: "logging.level", +as: LogLevel.self, +default: .info +) + +// Custom type conversion (ExpressibleByConfigString) +struct DatabaseURL: ExpressibleByConfigString { +let url: URL + +init?(configString: String) { +guard let url = URL(string: configString) else { return nil } +self.url = url +} + +var description: String { url.absoluteString } +} +let dbUrl = snapshot.string( +forKey: "database.url", +as: DatabaseURL.self +) + +### Access reporting + +When reading from a snapshot, access events are reported to the access reporter from the original config reader. This helps debug which config values are accessed, even when reading from snapshots. + +## Topics + +### Creating a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +### Namespacing + +Returns a scoped snapshot reader by appending the provided key to the current key prefix. + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +### Synchronously reading lists of string values + +Synchronously gets an array of config values for the given config key, converting from strings. + +Synchronously gets an array of config values for the given config key with default fallback, converting from strings. + +### Synchronously reading required string values + +Synchronously gets a required config value for the given config key, throwing an error if it’s missing. + +Synchronously gets a required config value for the given config key, converting from string. + +### Synchronously reading required lists of string values + +Synchronously gets a required array of config values for the given config key, converting from strings. + +### Synchronously reading Boolean values + +### Synchronously reading required Boolean values + +### Synchronously reading lists of Boolean values + +### Synchronously reading required lists of Boolean values + +### Synchronously reading integer values + +### Synchronously reading required integer values + +### Synchronously reading lists of integer values + +### Synchronously reading required lists of integer values + +### Synchronously reading double values + +### Synchronously reading required double values + +### Synchronously reading lists of double values + +### Synchronously reading required lists of double values + +### Synchronously reading bytes + +### Synchronously reading required bytes + +### Synchronously reading collections of byte chunks + +### Synchronously reading required collections of byte chunks + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Readers and providers + +`struct ConfigReader` + +A type that provides read-only access to configuration values from underlying providers. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +- ConfigSnapshotReader +- Overview +- Usage +- Scoping +- Config keys and context +- Automatic type conversion +- Access reporting +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue + +- Configuration +- ConfigContextValue + +Enumeration + +# ConfigContextValue + +A value that can be stored in a configuration context. + +enum ConfigContextValue + +ConfigContext.swift + +## Overview + +Context values support common data types used for configuration metadata. + +## Topics + +### Configuration context values + +`case string(String)` + +A string value. + +`case bool(Bool)` + +A Boolean value. + +`case int(Int)` + +An integer value. + +`case double(Double)` + +A floating point value. + +## Relationships + +### Conforms To + +- `Swift.Copyable` +- `Swift.CustomStringConvertible` +- `Swift.Equatable` +- `Swift.ExpressibleByBooleanLiteral` +- `Swift.ExpressibleByExtendedGraphemeClusterLiteral` +- `Swift.ExpressibleByFloatLiteral` +- `Swift.ExpressibleByIntegerLiteral` +- `Swift.ExpressibleByStringLiteral` +- `Swift.ExpressibleByUnicodeScalarLiteral` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Configuration keys + +`struct ConfigKey` + +A configuration key representing a relative path to a configuration value. + +`struct AbsoluteConfigKey` + +A configuration key that represents an absolute path to a configuration value. + +- ConfigContextValue +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader + +- Configuration +- ConfigReader + +Structure + +# ConfigReader + +A type that provides read-only access to configuration values from underlying providers. + +struct ConfigReader + +ConfigReader.swift + +## Mentioned in + +Configuring libraries + +Example use cases + +Using reloading providers + +## Overview + +Use `ConfigReader` to access configuration values from various sources like environment variables, JSON files, or in-memory stores. The reader supports provider hierarchies, key scoping, and access reporting for debugging configuration usage. + +## Usage + +To read configuration values, create a config reader with one or more providers: + +let config = ConfigReader(provider: EnvironmentVariablesProvider()) +let httpTimeout = config.int(forKey: "http.timeout", default: 60) + +### Using multiple providers + +Create a hierarchy of providers by passing an array to the initializer. The reader queries providers in order, using the first non-nil value it finds: + +do { +let config = ConfigReader(providers: [\ +// First, check environment variables\ +EnvironmentVariablesProvider(),\ +// Then, check a JSON config file\ + +// Finally, fall \ +]) + +// Uses the first provider that has a value for "http.timeout" +let timeout = config.int(forKey: "http.timeout", default: 15) +} catch { +print("Failed to create JSON provider: \(error)") +} + +The `get` and `fetch` methods query providers sequentially, while the `watch` method monitors all providers in parallel and returns the first non-nil value from the latest results. + +### Creating scoped readers + +Create a scoped reader to access nested configuration sections without repeating key prefixes. This is useful for passing configuration to specific components. + +Given this JSON configuration: + +{ +"http": { +"timeout": 60 +} +} + +Create a scoped reader for the HTTP section: + +let httpConfig = config.scoped(to: "http") +let timeout = httpConfig.int(forKey: "timeout") // Reads "http.timeout" + +### Understanding config keys + +The library accesses configuration values using config keys that represent a hierarchical path to the value. Internally, the library represents a key as a list of string components, such as `["http", "timeout"]`. + +### Using configuration context + +Provide additional context to help providers return more specific values. In the following example with a configuration that includes repeated configurations per “upstream”, the value returned is potentially constrained to the configuration with the matching context: + +let httpTimeout = config.int( +forKey: ConfigKey("http.timeout", context: ["upstream": "example.com"]), +default: 60 +) + +Providers can use this context to return specialized values or fall + +The library can automatically convert string configuration values to other types using the `as:` parameter. This works with: + +- Types that you explicitly conform to `ExpressibleByConfigString`. + +- Built-in types that already conform to `ExpressibleByConfigString`: + +- `SystemPackage.FilePath` \- Converts from file paths. + +- `Foundation.URL` \- Converts from URL strings. + +- `Foundation.UUID` \- Converts from UUID strings. + +- `Foundation.Date` \- Converts from ISO8601 date strings. + +// Built-in type conversion +let apiUrl = config.string(forKey: "api.url", as: URL.self) +let requestId = config.string( +forKey: "request.id", +as: UUID.self +) + +enum LogLevel: String { +case debug, info, warning, error +} +let logLevel = config.string( +forKey: "logging.level", +as: LogLevel.self, +default: .info +) + +// Custom type conversion (ExpressibleByConfigString) +struct DatabaseURL: ExpressibleByConfigString { +let url: URL + +init?(configString: String) { +guard let url = URL(string: configString) else { return nil } +self.url = url +} + +var description: String { url.absoluteString } +} +let dbUrl = config.string( +forKey: "database.url", +as: DatabaseURL.self +) + +### How providers encode keys + +Each `ConfigProvider` interprets config keys according to its data source format. For example, `EnvironmentVariablesProvider` converts `["http", "timeout"]` to the environment variable name `HTTP_TIMEOUT` by uppercasing components and joining with underscores. + +### Monitoring configuration access + +Use an access reporter to track which configuration values your application reads. The reporter receives `AccessEvent` instances containing the requested key, calling code location, returned value, and source provider. + +This helps debug configuration issues and to discover the config dependencies in your codebase. + +### Protecting sensitive values + +Mark sensitive configuration values as secrets to prevent logging by access loggers. Both config readers and providers can set the `isSecret` property. When either marks a value as sensitive, `AccessReporter` instances should not log the raw value. + +### Configuration context + +Configuration context supplements the configuration key components with extra metadata that providers can use to refine value lookups or return more specific results. Context is particularly useful for scenarios where the same configuration key might need different values based on runtime conditions. + +Create context using dictionary literal syntax with automatic type inference: + +let context: [String: ConfigContextValue] = [\ +"environment": "production",\ +"region": "us-west-2",\ +"timeout": 30,\ +"retryEnabled": true\ +] + +#### Provider behavior + +Not all providers use context information. Providers that support context can: + +- Return specialized values based on context keys. + +- Fall , +default: "localhost:5432" +) + +### Error handling behavior + +The config reader handles provider errors differently based on the method type: + +- **Get and watch methods**: Gracefully handle errors by returning `nil` or default values, except for “required” variants which rethrow errors. + +- **Fetch methods**: Always rethrow both provider and conversion errors. + +- **Required methods**: Rethrow all errors without fallback behavior. + +The library reports all provider errors to the access reporter through the `providerResults` array, even when handled gracefully. + +## Topics + +### Creating config readers + +`init(provider: some ConfigProvider, accessReporter: (any AccessReporter)?)` + +Creates a config reader with a single provider. + +[`init(providers: [any ConfigProvider], accessReporter: (any AccessReporter)?)`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/init(providers:accessreporter:)) + +Creates a config reader with multiple providers. + +### Retrieving a scoped config reader + +Returns a scoped config reader with the specified key appended to the current prefix. + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Readers and providers + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`struct ConfigSnapshotReader` + +A container type for reading config values from snapshots. + +Choosing the access pattern + +Learn how to select the right method for reading configuration values based on your needs. + +Choosing reader methods + +Choose between optional, default, and required variants of configuration methods. + +Handling secrets correctly + +Protect sensitive configuration values from accidental disclosure in logs and debug output. + +- ConfigReader +- Mentioned in +- Overview +- Usage +- Using multiple providers +- Creating scoped readers +- Understanding config keys +- Using configuration context +- Automatic type conversion +- How providers encode keys +- Monitoring configuration access +- Protecting sensitive values +- Configuration context +- Error handling behavior +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromstringdecoder + +- Configuration +- ConfigBytesFromStringDecoder + +Protocol + +# ConfigBytesFromStringDecoder + +A protocol for decoding string configuration values into byte arrays. + +protocol ConfigBytesFromStringDecoder : Sendable + +ConfigBytesFromStringDecoder.swift + +## Overview + +This protocol defines the interface for converting string-based configuration values into binary data. Different implementations can support various encoding formats such as base64, hexadecimal, or other custom encodings. + +## Usage + +Implementations of this protocol are used by configuration providers that need to convert string values to binary data, such as cryptographic keys, certificates, or other binary configuration data. + +let decoder: ConfigBytesFromStringDecoder = .base64 +let bytes = decoder.decode("SGVsbG8gV29ybGQ=") // "Hello World" in base64 + +## Topics + +### Required methods + +Decodes a string value into an array of bytes. + +**Required** + +### Built-in decoders + +`static var base64: ConfigBytesFromBase64StringDecoder` + +A decoder that interprets string values as base64-encoded data. + +`static var hex: ConfigBytesFromHexStringDecoder` + +A decoder that interprets string values as hexadecimal-encoded data. + +## Relationships + +### Inherits From + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Conforming Types + +- `ConfigBytesFromBase64StringDecoder` +- `ConfigBytesFromHexStringDecoder` + +## See Also + +### Value conversion + +`protocol ExpressibleByConfigString` + +A protocol for types that can be initialized from configuration string values. + +`struct ConfigBytesFromBase64StringDecoder` + +A decoder that converts base64-encoded strings into byte arrays. + +`struct ConfigBytesFromHexStringDecoder` + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +- ConfigBytesFromStringDecoder +- Overview +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfrombase64stringdecoder + +- Configuration +- ConfigBytesFromBase64StringDecoder + +Structure + +# ConfigBytesFromBase64StringDecoder + +A decoder that converts base64-encoded strings into byte arrays. + +struct ConfigBytesFromBase64StringDecoder + +ConfigBytesFromStringDecoder.swift + +## Overview + +This decoder interprets string configuration values as base64-encoded data and converts them to their binary representation. + +## Topics + +### Creating bytes from a base64 string + +`init()` + +Creates a new base64 decoder. + +## Relationships + +### Conforms To + +- `ConfigBytesFromStringDecoder` +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Value conversion + +`protocol ExpressibleByConfigString` + +A protocol for types that can be initialized from configuration string values. + +`protocol ConfigBytesFromStringDecoder` + +A protocol for decoding string configuration values into byte arrays. + +`struct ConfigBytesFromHexStringDecoder` + +A decoder that converts hexadecimal-encoded strings into byte arrays. + +- ConfigBytesFromBase64StringDecoder +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype + +- Configuration +- ConfigType + +Enumeration + +# ConfigType + +The supported configuration value types. + +@frozen +enum ConfigType + +ConfigProvider.swift + +## Topics + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +### Initializers + +`init?(rawValue: String)` + +## Relationships + +### Conforms To + +- `Swift.BitwiseCopyable` +- `Swift.Equatable` +- `Swift.Hashable` +- `Swift.RawRepresentable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`struct LookupResult` + +The result of looking up a configuration value in a provider. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- ConfigType +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent + +- Configuration +- AccessEvent + +Structure + +# AccessEvent + +An event that captures information about accessing a configuration value. + +struct AccessEvent + +AccessReporter.swift + +## Overview + +Access events are generated whenever configuration values are accessed through `ConfigReader` and `ConfigSnapshotReader` methods. They contain metadata about the access, results from individual providers, and the final outcome of the operation. + +## Topics + +### Creating an access event + +Creates a configuration access event. + +`struct Metadata` + +Metadata describing the configuration access operation. + +`struct ProviderResult` + +The result of a configuration lookup from a specific provider. + +### Inspecting an access event + +The final outcome of the configuration access operation. + +`var conversionError: (any Error)?` + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +`var metadata: AccessEvent.Metadata` + +Metadata that describes the configuration access operation. + +[`var providerResults: [AccessEvent.ProviderResult]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresults) + +The results from each configuration provider that was queried. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Troubleshooting and access reporting + +Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct BroadcastingAccessReporter` + +An access reporter that forwards events to multiple other reporters. + +- AccessEvent +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/broadcastingaccessreporter + +- Configuration +- BroadcastingAccessReporter + +Structure + +# BroadcastingAccessReporter + +An access reporter that forwards events to multiple other reporters. + +struct BroadcastingAccessReporter + +AccessReporter.swift + +## Overview + +Use this reporter to send configuration access events to multiple destinations simultaneously. Each upstream reporter receives a copy of every event in the order they were provided during initialization. + +let fileLogger = try FileAccessLogger(filePath: "/tmp/config.log") +let accessLogger = AccessLogger(logger: logger) +let broadcaster = BroadcastingAccessReporter(upstreams: [fileLogger, accessLogger]) + +let config = ConfigReader( +provider: EnvironmentVariablesProvider(), +accessReporter: broadcaster +) + +## Topics + +### Creating a broadcasting access reporter + +[`init(upstreams: [any AccessReporter])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/broadcastingaccessreporter/init(upstreams:)) + +Creates a new broadcasting access reporter. + +## Relationships + +### Conforms To + +- `AccessReporter` +- `Swift.Copyable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Troubleshooting and access reporting + +Troubleshooting and access reporting + +Check out some techniques to debug unexpected issues and to increase visibility into accessed config values. + +`protocol AccessReporter` + +A type that receives and processes configuration access events. + +`class AccessLogger` + +An access reporter that logs configuration access events using the Swift Log API. + +`class FileAccessLogger` + +An access reporter that writes configuration access events to a file. + +`struct AccessEvent` + +An event that captures information about accessing a configuration value. + +- BroadcastingAccessReporter +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/lookupresult + +- Configuration +- LookupResult + +Structure + +# LookupResult + +The result of looking up a configuration value in a provider. + +struct LookupResult + +ConfigProvider.swift + +## Overview + +Providers return this result from value lookup methods, containing both the encoded key used for the lookup and the value found: + +let result = try provider.value(forKey: key, type: .string) +if let value = result.value { +print("Found: \(value)") +} + +## Topics + +### Creating a lookup result + +`init(encodedKey: String, value: ConfigValue?)` + +Creates a lookup result. + +### Inspecting a lookup result + +`var encodedKey: String` + +The provider-specific encoding of the configuration key. + +`var value: ConfigValue?` + +The configuration value found for the key, or nil if not found. + +## Relationships + +### Conforms To + +- `Swift.Equatable` +- `Swift.Hashable` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a custom provider + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +`protocol FileParsingOptions` + +A type that provides parsing options for file configuration snapshots. + +`protocol ConfigProvider` + +A type that provides configuration values from a data source. + +`enum ConfigContent` + +The raw content of a configuration value. + +`struct ConfigValue` + +A configuration value that wraps content with metadata. + +`enum ConfigType` + +The supported configuration value types. + +`enum SecretsSpecifier` + +A specification for identifying which configuration values contain sensitive information. + +`struct ConfigUpdatesAsyncSequence` + +A concrete async sequence for delivering updated configuration values. + +- LookupResult +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/proposals + +- Configuration +- Proposals + +# Proposals + +Collaborate on API changes to Swift Configuration by writing a proposal. + +## Overview + +For non-trivial changes that affect the public API, the Swift Configuration project adopts a lightweight version of the Swift Evolution process. + +Writing a proposal first helps discuss multiple possible solutions early, apply useful feedback from other contributors, and avoid reimplementing the same feature multiple times. + +While it’s encouraged to get feedback by opening a pull request with a proposal early in the process, it’s also important to consider the complexity of the implementation when evaluating different solutions. For example, this might mean including a link to a branch containing a prototype implementation of the feature in the pull request description. + +### Steps + +1. Make sure there’s a GitHub issue for the feature or change you would like to propose. + +2. Duplicate the `SCO-NNNN.md` document and replace `NNNN` with the next available proposal number. + +3. Link the GitHub issue from your proposal, and fill in the proposal. + +4. Open a pull request with your proposal and solicit feedback from other contributors. + +5. Once a maintainer confirms that the proposal is ready for review, the state is updated accordingly. The review period is 7 days, and ends when one of the maintainers marks the proposal as Ready for Implementation, or Deferred. + +6. Before the pull request is merged, there should be an implementation ready, either in the same pull request, or a separate one, linked from the proposal. + +7. The proposal is considered Approved once the implementation, proposal PRs have been merged, and, if originally disabled by a feature flag, feature flag enabled unconditionally. + +If you have any questions, ask in an issue on GitHub. + +### Possible review states + +- Awaiting Review + +- In Review + +- Ready for Implementation + +- In Preview + +- Approved + +- Deferred + +## Topics + +SCO-NNNN: Feature name + +Feature abstract – a one sentence summary. + +SCO-0001: Generic file providers + +Introduce format-agnostic providers to simplify implementing additional file formats beyond JSON and YAML. + +SCO-0002: Remove custom key decoders + +Remove the custom key decoder feature to fix a flaw and simplify the project + +SCO-0003: Allow missing files in file providers + +Add an `allowMissing` parameter to file-based providers to handle missing configuration files gracefully. + +## See Also + +### Contributing + +Developing Swift Configuration + +Learn about tools and conventions used to develop the Swift Configuration package. + +- Proposals +- Overview +- Steps +- Possible review states +- Topics +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/inmemoryprovider + +- Configuration +- InMemoryProvider + +Structure + +# InMemoryProvider + +A configuration provider that stores values in memory. + +struct InMemoryProvider + +InMemoryProvider.swift + +## Mentioned in + +Using in-memory providers + +Configuring applications + +Example use cases + +## Overview + +This provider maintains a static dictionary of configuration values in memory, making it ideal for providing default values, overrides, or test configurations. Values are immutable once the provider is created and never change over time. + +## Use cases + +The in-memory provider is particularly useful for: + +- **Default configurations**: Providing fallback values when other providers don’t have a value + +- **Configuration overrides**: Taking precedence over other providers + +- **Testing**: Creating predictable configuration states for unit tests + +- **Static configurations**: Embedding compile-time configuration values + +## Value types + +The provider supports all standard configuration value types and automatically handles type validation. Values must match the requested type exactly - no automatic conversion is performed - for example, requesting a `String` value for a key that stores an `Int` value will throw an error. + +## Performance characteristics + +This provider offers O(1) lookup time and performs no I/O operations. All values are stored in memory. + +## Usage + +let provider = InMemoryProvider(values: [\ +"http.client.user-agent": "Config/1.0 (Test)",\ +"http.client.timeout": 15.0,\ +"http.secret": ConfigValue("s3cret", isSecret: true),\ +"http.version": 2,\ +"enabled": true\ +]) +// Prints all values, redacts "http.secret" automatically. +print(provider) +let config = ConfigReader(provider: provider) +let isEnabled = config.bool(forKey: "enabled", default: false) + +To learn more about the in-memory providers, check out Using in-memory providers. + +## Topics + +### Creating an in-memory provider + +[`init(name: String?, values: [AbsoluteConfigKey : ConfigValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/inmemoryprovider/init(name:values:)) + +Creates a new in-memory provider with the specified configuration values. + +## Relationships + +### Conforms To + +- `ConfigProvider` +- `Swift.Copyable` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Built-in providers + +`struct EnvironmentVariablesProvider` + +A configuration provider that sources values from environment variables. + +`struct CommandLineArgumentsProvider` + +A configuration provider that sources values from command-line arguments. + +`struct FileProvider` + +A configuration provider that reads from a file on disk using a configurable snapshot type. + +`class ReloadingFileProvider` + +A configuration provider that reads configuration from a file on disk with automatic reloading capability. + +`struct JSONSnapshot` + +A snapshot of configuration values parsed from JSON data. + +`class YAMLSnapshot` + +A snapshot of configuration values parsed from YAML data. + +Using reloading providers + +Automatically reload configuration from files when they change. + +`struct DirectoryFilesProvider` + +A configuration provider that reads values from individual files in a directory. + +Learn about the `InMemoryProvider` and `MutableInMemoryProvider` built-in types. + +`class MutableInMemoryProvider` + +A configuration provider that stores mutable values in memory. + +`struct KeyMappingProvider` + +A configuration provider that maps all keys before delegating to an upstream provider. + +- InMemoryProvider +- Mentioned in +- Overview +- Use cases +- Value types +- Performance characteristics +- Usage +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configuring-libraries + +- Configuration +- Configuring libraries + +Article + +# Configuring libraries + +Provide a consistent and flexible way to configure your library. + +## Overview + +Swift Configuration provides a pattern for configuring libraries that works across various configuration sources: environment variables, JSON files, and remote configuration services. + +This guide shows how to adopt this pattern in your library to make it easier to compose in larger applications. + +Adopt this pattern in three steps: + +1. Define your library’s configuration as a dedicated type (you might already have such a type in your library). + +2. Add a convenience method that accepts a `ConfigReader` \- can be an initializer, or a method that updates your configuration. + +3. Extract the individual configuration values using the provided reader. + +This approach makes your library configurable regardless of the user’s chosen configuration source and composes well with other libraries. + +### Define your configuration type + +Start by defining a type that encapsulates all the configuration options for your library. + +/// Configuration options for a hypothetical HTTPClient. +public struct HTTPClientConfiguration { +/// The timeout for network requests in seconds. +public var timeout: Double + +/// The maximum number of concurrent connections. +public var maxConcurrentConnections: Int + +/// Base URL for API requests. +public var baseURL: String + +/// Whether to enable debug logging. +public var debugLogging: Bool + +/// Create a configuration with explicit values. +public init( +timeout: Double = 30.0, +maxConcurrentConnections: Int = 5, +baseURL: String = "https://api.example.com", +debugLogging: Bool = false +) { +self.timeout = timeout +self.maxConcurrentConnections = maxConcurrentConnections +self.baseURL = baseURL +self.debugLogging = debugLogging +} +} + +### Add a convenience method + +Next, extend your configuration type to provide a method that accepts a `ConfigReader` as a parameter. In the example below, we use an initializer. + +extension HTTPClientConfiguration { +/// Creates a new HTTP client configuration using values from the provided reader. +/// +/// ## Configuration keys +/// - `timeout` (double, optional, default: `30.0`): The timeout for network requests in seconds. +/// - `maxConcurrentConnections` (int, optional, default: `5`): The maximum number of concurrent connections. +/// - `baseURL` (string, optional, default: `"https://api.example.com"`): Base URL for API requests. +/// - `debugLogging` (bool, optional, default: `false`): Whether to enable debug logging. +/// +/// - Parameter config: The config reader to read configuration values from. +public init(config: ConfigReader) { +self.timeout = config.double(forKey: "timeout", default: 30.0) +self.maxConcurrentConnections = config.int(forKey: "maxConcurrentConnections", default: 5) +self.baseURL = config.string(forKey: "baseURL", default: "https://api.example.com") +self.debugLogging = config.bool(forKey: "debugLogging", default: false) +} +} + +### Example: Adopting your library + +Once you’ve made your library configurable, users can easily configure it from various sources. Here’s how someone might configure your library using environment variables: + +import Configuration +import YourHTTPLibrary + +// Create a config reader from environment variables. +let config = ConfigReader(provider: EnvironmentVariablesProvider()) + +// Initialize your library's configuration from a config reader. +let httpConfig = HTTPClientConfiguration(config: config) + +// Create your library instance with the configuration. +let httpClient = HTTPClient(configuration: httpConfig) + +// Start using your library. +httpClient.get("/users") { response in +// Handle the response. +} + +With this approach, users can configure your library by setting environment variables that match your config keys: + +# Set configuration for your library through environment variables. +export TIMEOUT=60.0 +export MAX_CONCURRENT_CONNECTIONS=10 +export BASE_URL="https://api.production.com" +export DEBUG_LOGGING=true + +Your library now adapts to the user’s environment without any code changes. + +### Working with secrets + +Mark configuration values that contain sensitive information as secret to prevent them from being logged: + +extension HTTPClientConfiguration { +public init(config: ConfigReader) throws { +self.apiKey = try config.requiredString(forKey: "apiKey", isSecret: true) +// Other configuration... +} +} + +Built-in `AccessReporter` types such as `AccessLogger` and `FileAccessLogger` automatically redact secret values to avoid leaking sensitive information. + +For more guidance on secrets handling, see Handling secrets correctly. For more configuration guidance, check out Adopting best practices. To understand different access patterns and reader methods, refer to Choosing the access pattern and Choosing reader methods. + +## See Also + +### Essentials + +Configuring applications + +Provide flexible and consistent configuration for your application. + +Example use cases + +Review common use cases with ready-to-copy code samples. + +Adopting best practices + +Follow these principles to make your code easily configurable and composable with other libraries. + +- Configuring libraries +- Overview +- Define your configuration type +- Add a convenience method +- Example: Adopting your library +- Working with secrets +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/configsnapshot-implementations + +- Configuration +- YAMLSnapshot +- ConfigSnapshot Implementations + +API Collection + +# ConfigSnapshot Implementations + +## Topics + +### Instance Methods + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/init(data:providername:parsingoptions:) + +#app-main) + +- Configuration +- YAMLSnapshot +- init(data:providerName:parsingOptions:) + +Initializer + +# init(data:providerName:parsingOptions:) + +Inherited from `FileConfigSnapshot.init(data:providerName:parsingOptions:)`. + +convenience init( +data: RawSpan, +providerName: String, +parsingOptions: YAMLSnapshot.ParsingOptions +) throws + +YAMLSnapshot.swift + +## See Also + +### Creating a YAML snapshot + +`struct ParsingOptions` + +Custom input configuration for YAML snapshot creation. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/parsingoptions + +- Configuration +- YAMLSnapshot +- YAMLSnapshot.ParsingOptions + +Structure + +# YAMLSnapshot.ParsingOptions + +Custom input configuration for YAML snapshot creation. + +struct ParsingOptions + +YAMLSnapshot.swift + +## Overview + +This struct provides configuration options for parsing YAML data into configuration snapshots, including byte decoding and secrets specification. + +## Topics + +### Initializers + +Creates custom input configuration for YAML snapshots. + +### Instance Properties + +`var bytesDecoder: any ConfigBytesFromStringDecoder` + +A decoder of bytes from a string. + +A specifier for determining which configuration values should be treated as secrets. + +### Type Properties + +``static var `default`: YAMLSnapshot.ParsingOptions`` + +The default custom input configuration. + +## Relationships + +### Conforms To + +- `FileParsingOptions` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a YAML snapshot + +`convenience init(data: RawSpan, providerName: String, parsingOptions: YAMLSnapshot.ParsingOptions) throws` + +- YAMLSnapshot.ParsingOptions +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting/providercompattest + +- ConfigurationTesting +- ProviderCompatTest + +Structure + +# ProviderCompatTest + +A comprehensive test suite for validating `ConfigProvider` implementations. + +struct ProviderCompatTest + +ProviderCompatTest.swift + +## Overview + +This test suite verifies that configuration providers correctly implement all required functionality including synchronous and asynchronous value retrieval, snapshot operations, and value watching capabilities. + +## Usage + +Create a test instance with your provider and run the compatibility tests: + +let provider = MyCustomProvider() +let test = ProviderCompatTest(provider: provider) +try await test.runTest() + +## Required Test Data + +The provider under test must be populated with specific test values to ensure comprehensive validation. The required configuration data includes: + +\ +"string": String("Hello"),\ +"other.string": String("Other Hello"),\ +"int": Int(42),\ +"other.int": Int(24),\ +"double": Double(3.14),\ +"other.double": Double(2.72),\ +"bool": Bool(true),\ +"other.bool": Bool(false),\ +"bytes": [UInt8,\ +"other.bytes": UInt8,\ +"stringy.array": String,\ +"other.stringy.array": String,\ +"inty.array": Int,\ +"other.inty.array": Int,\ +"doubly.array": Double,\ +"other.doubly.array": Double,\ +"booly.array": Bool,\ +"other.booly.array": Bool,\ +"byteChunky.array": [[UInt8]]([.magic, .magic2]),\ +"other.byteChunky.array": [[UInt8]]([.magic, .magic2, .magic]),\ +] + +## Topics + +### Structures + +`struct TestConfiguration` + +Configuration options for customizing test behavior. + +### Initializers + +`init(provider: any ConfigProvider, configuration: ProviderCompatTest.TestConfiguration)` + +Creates a new compatibility test suite. + +### Instance Methods + +`func runTest() async throws` + +Executes the complete compatibility test suite. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +- ProviderCompatTest +- Overview +- Usage +- Required Test Data +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileconfigsnapshot + +- Configuration +- FileConfigSnapshot + +Protocol + +# FileConfigSnapshot + +A protocol for configuration snapshots created from file data. + +protocol FileConfigSnapshot : ConfigSnapshot, CustomDebugStringConvertible, CustomStringConvertible + +FileProviderSnapshot.swift + +## Overview + +This protocol extends `ConfigSnapshot` to provide file-specific functionality for creating configuration snapshots from raw file data. Types conforming to this protocol can parse various file formats (such as JSON and YAML) and convert them into configuration values. + +Commonly used with `FileProvider` and `ReloadingFileProvider`. + +## Implementation + +To create a custom file configuration snapshot: + +struct MyFormatSnapshot: FileConfigSnapshot { +typealias ParsingOptions = MyParsingOptions + +let values: [String: ConfigValue] +let providerName: String + +init(data: RawSpan, providerName: String, parsingOptions: MyParsingOptions) throws { +self.providerName = providerName +// Parse the data according to your format +self.values = try parseMyFormat(data, using: parsingOptions) +} +} + +The snapshot is responsible for parsing the file data and converting it into a representation of configuration values that can be queried by the configuration system. + +## Topics + +### Required methods + +`init(data: RawSpan, providerName: String, parsingOptions: Self.ParsingOptions) throws` + +Creates a new snapshot from file data. + +**Required** + +`associatedtype ParsingOptions : FileParsingOptions` + +The parsing options type used for parsing this snapshot. + +### Protocol requirements + +`protocol ConfigSnapshot` + +An immutable snapshot of a configuration provider’s state. + +## Relationships + +### Inherits From + +- `ConfigSnapshot` +- `Swift.CustomDebugStringConvertible` +- `Swift.CustomStringConvertible` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +### Conforming Types + +- `JSONSnapshot` +- `YAMLSnapshot` + +- FileConfigSnapshot +- Overview +- Implementation +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/providername + +- Configuration +- YAMLSnapshot +- providerName + +Instance Property + +# providerName + +The name of the provider that created this snapshot. + +let providerName: String + +YAMLSnapshot.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/customdebugstringconvertible-implementations + +- Configuration +- YAMLSnapshot +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/customstringconvertible-implementations + +- Configuration +- YAMLSnapshot +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/fileconfigsnapshot-implementations + +- Configuration +- YAMLSnapshot +- FileConfigSnapshot Implementations + +API Collection + +# FileConfigSnapshot Implementations + +## Topics + +### Initializers + +`convenience init(data: RawSpan, providerName: String, parsingOptions: YAMLSnapshot.ParsingOptions) throws` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accesslogger/accessreporter-implementations + +- Configuration +- AccessLogger +- AccessReporter Implementations + +API Collection + +# AccessReporter Implementations + +## Topics + +### Instance Methods + +`func report(AccessEvent)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/init(data:providername:parsingoptions:) + +#app-main) + +- Configuration +- JSONSnapshot +- init(data:providerName:parsingOptions:) + +Initializer + +# init(data:providerName:parsingOptions:) + +Inherited from `FileConfigSnapshot.init(data:providerName:parsingOptions:)`. + +init( +data: RawSpan, +providerName: String, +parsingOptions: JSONSnapshot.ParsingOptions +) throws + +JSONSnapshot.swift + +## See Also + +### Creating a JSON snapshot + +`struct ParsingOptions` + +Parsing options for JSON snapshot creation. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/customstringconvertible-implementations + +- Configuration +- JSONSnapshot +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/parsingoptions + +- Configuration +- JSONSnapshot +- JSONSnapshot.ParsingOptions + +Structure + +# JSONSnapshot.ParsingOptions + +Parsing options for JSON snapshot creation. + +struct ParsingOptions + +JSONSnapshot.swift + +## Overview + +This struct provides configuration options for parsing JSON data into configuration snapshots, including byte decoding and secrets specification. + +## Topics + +### Initializers + +Creates parsing options for JSON snapshots. + +### Instance Properties + +`var bytesDecoder: any ConfigBytesFromStringDecoder` + +A decoder of bytes from a string. + +A specifier for determining which configuration values should be treated as secrets. + +### Type Properties + +``static var `default`: JSONSnapshot.ParsingOptions`` + +The default parsing options. + +## Relationships + +### Conforms To + +- `FileParsingOptions` +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating a JSON snapshot + +`init(data: RawSpan, providerName: String, parsingOptions: JSONSnapshot.ParsingOptions) throws` + +- JSONSnapshot.ParsingOptions +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/customdebugstringconvertible-implementations + +- Configuration +- JSONSnapshot +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/run() + +#app-main) + +- Configuration +- ReloadingFileProvider +- run() + +Instance Method + +# run() + +Inherited from `Service.run()`. + +func run() async throws + +ReloadingFileProvider.swift + +Available when `Snapshot` conforms to `FileConfigSnapshot`. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/providername + +- Configuration +- JSONSnapshot +- providerName + +Instance Property + +# providerName + +The name of the provider that created this snapshot. + +let providerName: String + +JSONSnapshot.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/fileconfigsnapshot-implementations + +- Configuration +- JSONSnapshot +- FileConfigSnapshot Implementations + +API Collection + +# FileConfigSnapshot Implementations + +## Topics + +### Initializers + +`init(data: RawSpan, providerName: String, parsingOptions: JSONSnapshot.ParsingOptions) throws` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accesslogger/init(logger:level:message:) + +#app-main) + +- Configuration +- AccessLogger +- init(logger:level:message:) + +Initializer + +# init(logger:level:message:) + +Creates a new access logger that reports configuration access events. + +init( +logger: Logger, +level: Logger.Level = .debug, +message: Logger.Message = "Config value accessed" +) + +AccessLogger.swift + +## Parameters + +`logger` + +The logger to emit access events to. + +`level` + +The log level for access events. Defaults to `.debug`. + +`message` + +The static message text for log entries. Defaults to “Config value accessed”. + +## Discussion + +let logger = Logger(label: "my.app.config") + +// Log at debug level by default +let accessLogger = AccessLogger(logger: logger) + +// Customize the log level +let accessLogger = AccessLogger(logger: logger, level: .info) + +- init(logger:level:message:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/providername + +- Configuration +- ReloadingFileProvider +- providerName + +Instance Property + +# providerName + +The human-readable name of the provider. + +let providerName: String + +ReloadingFileProvider.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/jsonsnapshot/configsnapshot-implementations + +- Configuration +- JSONSnapshot +- ConfigSnapshot Implementations + +API Collection + +# ConfigSnapshot Implementations + +## Topics + +### Instance Methods + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/init(snapshottype:parsingoptions:filepath:allowmissing:pollinterval:logger:metrics:) + +#app-main) + +- Configuration +- ReloadingFileProvider +- init(snapshotType:parsingOptions:filePath:allowMissing:pollInterval:logger:metrics:) + +Initializer + +# init(snapshotType:parsingOptions:filePath:allowMissing:pollInterval:logger:metrics:) + +Creates a reloading file provider that monitors the specified file path. + +convenience init( +snapshotType: Snapshot.Type = Snapshot.self, +parsingOptions: Snapshot.ParsingOptions = .default, +filePath: FilePath, +allowMissing: Bool = false, +pollInterval: Duration = .seconds(15), +logger: Logger = Logger(label: "ReloadingFileProvider"), +metrics: any MetricsFactory = MetricsSystem.factory +) async throws + +ReloadingFileProvider.swift + +## Parameters + +`snapshotType` + +The type of snapshot to create from the file contents. + +`parsingOptions` + +Options used by the snapshot to parse the file data. + +`filePath` + +The path to the configuration file to monitor. + +`allowMissing` + +A flag controlling how the provider handles a missing file. + +- When `false` (the default), if the file is missing or malformed, throws an error. + +- When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +`pollInterval` + +How often to check for file changes. + +`logger` + +The logger instance to use for this provider. + +`metrics` + +The metrics factory to use for monitoring provider performance. + +## Discussion + +## See Also + +### Creating a reloading file provider + +`convenience init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, config: ConfigReader, logger: Logger, metrics: any MetricsFactory) async throws` + +Creates a reloading file provider using configuration from a reader. + +- init(snapshotType:parsingOptions:filePath:allowMissing:pollInterval:logger:metrics:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/customdebugstringconvertible-implementations + +- Configuration +- ReloadingFileProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/customstringconvertible-implementations + +- Configuration +- ReloadingFileProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/init(snapshottype:parsingoptions:config:) + +#app-main) + +- Configuration +- FileProvider +- init(snapshotType:parsingOptions:config:) + +Initializer + +# init(snapshotType:parsingOptions:config:) + +Creates a file provider using a file path from a configuration reader. + +init( +snapshotType: Snapshot.Type = Snapshot.self, +parsingOptions: Snapshot.ParsingOptions = .default, +config: ConfigReader +) async throws + +FileProvider.swift + +## Parameters + +`snapshotType` + +The type of snapshot to create from the file contents. + +`parsingOptions` + +Options used by the snapshot to parse the file data. + +`config` + +A configuration reader that contains the required configuration keys. + +## Discussion + +This initializer reads the file path from the provided configuration reader and creates a snapshot from that file. + +## Configuration keys + +- `filePath` (string, required): The path to the configuration file to read. + +- `allowMissing` (bool, optional, default: false): A flag controlling how the provider handles a missing file. When `false` (the default), if the file is missing or malformed, throws an error. When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +## See Also + +### Creating a file provider + +`init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, allowMissing: Bool) async throws` + +Creates a file provider that reads from the specified file path. + +- init(snapshotType:parsingOptions:config:) +- Parameters +- Discussion +- Configuration keys +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/customstringconvertible-implementations + +- Configuration +- FileProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/customdebugstringconvertible-implementations + +- Configuration +- FileProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/init(snapshottype:parsingoptions:filepath:allowmissing:) + +#app-main) + +- Configuration +- FileProvider +- init(snapshotType:parsingOptions:filePath:allowMissing:) + +Initializer + +# init(snapshotType:parsingOptions:filePath:allowMissing:) + +Creates a file provider that reads from the specified file path. + +init( +snapshotType: Snapshot.Type = Snapshot.self, +parsingOptions: Snapshot.ParsingOptions = .default, +filePath: FilePath, +allowMissing: Bool = false +) async throws + +FileProvider.swift + +## Parameters + +`snapshotType` + +The type of snapshot to create from the file contents. + +`parsingOptions` + +Options used by the snapshot to parse the file data. + +`filePath` + +The path to the configuration file to read. + +`allowMissing` + +A flag controlling how the provider handles a missing file. + +- When `false` (the default), if the file is missing or malformed, throws an error. + +- When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +## Discussion + +This initializer reads the file at the given path and creates a snapshot using the specified snapshot type. The file is read once during initialization. + +## See Also + +### Creating a file provider + +`init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, config: ConfigReader) async throws` + +Creates a file provider using a file path from a configuration reader. + +- init(snapshotType:parsingOptions:filePath:allowMissing:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/configprovider-implementations + +- Configuration +- ReloadingFileProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/init(environmentvariables:secretsspecifier:bytesdecoder:arrayseparator:) + +#app-main) + +- Configuration +- EnvironmentVariablesProvider +- init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:) + +Initializer + +# init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:) + +Creates a new provider from a custom dictionary of environment variables. + +init( +environmentVariables: [String : String], + +bytesDecoder: some ConfigBytesFromStringDecoder = .base64, +arraySeparator: Character = "," +) + +EnvironmentVariablesProvider.swift + +## Parameters + +`environmentVariables` + +A dictionary of environment variable names and values. + +`secretsSpecifier` + +Specifies which environment variables should be treated as secrets. + +`bytesDecoder` + +The decoder used for converting string values to byte arrays. + +`arraySeparator` + +The character used to separate elements in array values. + +## Discussion + +This initializer allows you to provide a custom set of environment variables, which is useful for testing or when you want to override specific values. + +let customEnvironment = [\ +"DATABASE_HOST": "localhost",\ +"DATABASE_PORT": "5432",\ +"API_KEY": "secret-key"\ +] +let provider = EnvironmentVariablesProvider( +environmentVariables: customEnvironment, +secretsSpecifier: .specific(["API_KEY"]) +) + +## See Also + +### Creating an environment variable provider + +Creates a new provider that reads from the current process environment. + +Creates a new provider that reads from an environment file. + +- init(environmentVariables:secretsSpecifier:bytesDecoder:arraySeparator:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/context + +- Configuration +- AbsoluteConfigKey +- context + +Instance Property + +# context + +Additional context information for this configuration key. + +var context: [String : ConfigContextValue] + +ConfigKey.swift + +## Discussion + +Context provides extra information that providers can use to refine lookups or return more specific values. Not all providers use context information. + +## See Also + +### Inspecting an absolute configuration key + +[`var components: [String]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/components) + +The hierarchical components that make up this absolute configuration key. + +- context +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider/customstringconvertible-implementations + +- Configuration +- CommandLineArgumentsProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/watchint(forkey:issecret:fileid:line:updateshandler:) + +#app-main) + +- Configuration +- ConfigReader +- watchInt(forKey:isSecret:fileID:line:updatesHandler:) + +Instance Method + +# watchInt(forKey:isSecret:fileID:line:updatesHandler:) + +Watches for updates to a config value for the given config key. + +forKey key: ConfigKey, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line, + +ConfigReader+methods.swift + +## Parameters + +`key` + +The config key to watch. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +`updatesHandler` + +A closure that handles an async sequence of updates to the value. The sequence produces `nil` if the value is missing or can’t be converted. + +## Return Value + +The result produced by the handler. + +## Mentioned in + +Example use cases + +## Discussion + +Use this method to observe changes to optional configuration values over time. The handler receives an async sequence that produces the current value whenever it changes, or `nil` if the value is missing or can’t be converted. + +try await config.watchInt(forKey: ["server", "port"]) { updates in +for await port in updates { +if let port = port { +print("Server port is: \(port)") +} else { +print("No server port configured") +} +} +} + +## See Also + +### Watching integer values + +Watches for updates to a config value for the given config key with default fallback. + +- watchInt(forKey:isSecret:fileID:line:updatesHandler:) +- Parameters +- Return Value +- Mentioned in +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/service-implementations + +- Configuration +- ReloadingFileProvider +- Service Implementations + +API Collection + +# Service Implementations + +## Topics + +### Instance Methods + +`func run() async throws` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/customstringconvertible-implementations + +- Configuration +- ConfigKey +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-6vten + +-6vten#app-main) + +- Configuration +- ConfigKey +- init(\_:context:) + +Initializer + +# init(\_:context:) + +Creates a new configuration key. + +init( +_ string: String, +context: [String : ConfigContextValue] = [:] +) + +ConfigKey.swift + +## Parameters + +`string` + +The string representation of the key path, for example `"http.timeout"`. + +`context` + +Additional context information for the key. + +## See Also + +### Creating a configuration key + +[`init([String], context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-9ifez) + +- init(\_:context:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/environmentvalue(forname:) + +#app-main) + +- Configuration +- EnvironmentVariablesProvider +- environmentValue(forName:) + +Instance Method + +# environmentValue(forName:) + +Returns the raw string value for a specific environment variable name. + +EnvironmentVariablesProvider.swift + +## Parameters + +`name` + +The exact name of the environment variable to retrieve. + +## Return Value + +The string value of the environment variable, or nil if not found. + +## Discussion + +This method provides direct access to environment variable values by name, without any key transformation or type conversion. It’s useful when you need to access environment variables that don’t follow the standard configuration key naming conventions. + +let provider = EnvironmentVariablesProvider() +let path = try provider.environmentValue(forName: "PATH") +let home = try provider.environmentValue(forName: "HOME") + +- environmentValue(forName:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/init(environmentfilepath:allowmissing:secretsspecifier:bytesdecoder:arrayseparator:) + +#app-main) + +- Configuration +- EnvironmentVariablesProvider +- init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:) + +Initializer + +# init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:) + +Creates a new provider that reads from an environment file. + +init( +environmentFilePath: FilePath, +allowMissing: Bool = false, + +bytesDecoder: some ConfigBytesFromStringDecoder = .base64, +arraySeparator: Character = "," +) async throws + +EnvironmentVariablesProvider.swift + +## Parameters + +`environmentFilePath` + +The file system path to the environment file to load. + +`allowMissing` + +A flag controlling how the provider handles a missing file. + +- When `false` (the default), if the file is missing or malformed, throws an error. + +- When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +`secretsSpecifier` + +Specifies which environment variables should be treated as secrets. + +`bytesDecoder` + +The decoder used for converting string values to byte arrays. + +`arraySeparator` + +The character used to separate elements in array values. + +## Discussion + +This initializer loads environment variables from an `.env` file at the specified path. The file should contain key-value pairs in the format `KEY=value`, one per line. Comments (lines starting with `#`) and empty lines are ignored. + +// Load from a .env file +let provider = try await EnvironmentVariablesProvider( +environmentFilePath: ".env", +allowMissing: true, +secretsSpecifier: .specific(["API_KEY"]) +) + +## See Also + +### Creating an environment variable provider + +Creates a new provider that reads from the current process environment. + +Creates a new provider from a custom dictionary of environment variables. + +- init(environmentFilePath:allowMissing:secretsSpecifier:bytesDecoder:arraySeparator:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/customstringconvertible-implementations + +- Configuration +- EnvironmentVariablesProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider/init(upstream:keymapper:) + +#app-main) + +- Configuration +- KeyMappingProvider +- init(upstream:keyMapper:) + +Initializer + +# init(upstream:keyMapper:) + +Creates a new provider. + +init( +upstream: Upstream, + +) + +KeyMappingProvider.swift + +## Parameters + +`upstream` + +The upstream provider to delegate to after mapping. + +`mapKey` + +A closure to remap configuration keys. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configprovider/prefixkeys(with:) + +#app-main) + +- Configuration +- ConfigProvider +- prefixKeys(with:) + +Instance Method + +# prefixKeys(with:) + +Creates a new prefixed configuration provider. + +ConfigProvider+Operators.swift + +## Return Value + +A provider which prefixes keys with the given prefix. + +## Discussion + +- Parameter: prefix: The configuration key to prepend to all configuration keys. + +## See Also + +### Conveniences + +Implements `watchValue` by getting the current value and emitting it immediately. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Creates a new configuration provider where each key is rewritten by the given closure. + +- prefixKeys(with:) +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/customdebugstringconvertible-implementations + +- Configuration +- EnvironmentVariablesProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileprovider/configprovider-implementations + +- Configuration +- FileProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/reloadingfileprovider/init(snapshottype:parsingoptions:config:logger:metrics:) + +#app-main) + +- Configuration +- ReloadingFileProvider +- init(snapshotType:parsingOptions:config:logger:metrics:) + +Initializer + +# init(snapshotType:parsingOptions:config:logger:metrics:) + +Creates a reloading file provider using configuration from a reader. + +convenience init( +snapshotType: Snapshot.Type = Snapshot.self, +parsingOptions: Snapshot.ParsingOptions = .default, +config: ConfigReader, +logger: Logger = Logger(label: "ReloadingFileProvider"), +metrics: any MetricsFactory = MetricsSystem.factory +) async throws + +ReloadingFileProvider.swift + +## Parameters + +`snapshotType` + +The type of snapshot to create from the file contents. + +`parsingOptions` + +Options used by the snapshot to parse the file data. + +`config` + +A configuration reader that contains the required configuration keys. + +`logger` + +The logger instance to use for this provider. + +`metrics` + +The metrics factory to use for monitoring provider performance. + +## Configuration keys + +- `filePath` (string, required): The path to the configuration file to monitor. + +- `allowMissing` (bool, optional, default: false): A flag controlling how the provider handles a missing file. When `false` (the default), if the file is missing or malformed, throws an error. When `true`, if the file is missing, treats it as empty. Malformed files still throw an error. + +- `pollIntervalSeconds` (int, optional, default: 15): How often to check for file changes in seconds. + +## See Also + +### Creating a reloading file provider + +`convenience init(snapshotType: Snapshot.Type, parsingOptions: Snapshot.ParsingOptions, filePath: FilePath, allowMissing: Bool, pollInterval: Duration, logger: Logger, metrics: any MetricsFactory) async throws` + +Creates a reloading file provider that monitors the specified file path. + +- init(snapshotType:parsingOptions:config:logger:metrics:) +- Parameters +- Configuration keys +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/equatable-implementations + +- Configuration +- ConfigKey +- Equatable Implementations + +API Collection + +# Equatable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/expressiblebyextendedgraphemeclusterliteral-implementations + +- Configuration +- ConfigKey +- ExpressibleByExtendedGraphemeClusterLiteral Implementations + +API Collection + +# ExpressibleByExtendedGraphemeClusterLiteral Implementations + +## Topics + +### Initializers + +`init(unicodeScalarLiteral: Self.ExtendedGraphemeClusterLiteralType)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/comparable-implementations + +- Configuration +- ConfigKey +- Comparable Implementations + +API Collection + +# Comparable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-9ifez + +-9ifez#app-main) + +- Configuration +- ConfigKey +- init(\_:context:) + +Initializer + +# init(\_:context:) + +Creates a new configuration key. + +init( +_ components: [String], +context: [String : ConfigContextValue] = [:] +) + +ConfigKey.swift + +## Parameters + +`components` + +The hierarchical components that make up the key path. + +`context` + +Additional context information for the key. + +## See Also + +### Creating a configuration key + +[`init(String, context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/init(_:context:)-6vten) + +- init(\_:context:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider/customdebugstringconvertible-implementations + +- Configuration +- KeyMappingProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider/init(arguments:secretsspecifier:bytesdecoder:) + +#app-main) + +- Configuration +- CommandLineArgumentsProvider +- init(arguments:secretsSpecifier:bytesDecoder:) + +Initializer + +# init(arguments:secretsSpecifier:bytesDecoder:) + +Creates a new CLI provider with the provided arguments. + +init( +arguments: [String] = CommandLine.arguments, + +bytesDecoder: some ConfigBytesFromStringDecoder = .base64 +) + +CommandLineArgumentsProvider.swift + +## Parameters + +`arguments` + +The command-line arguments to parse. + +`secretsSpecifier` + +Specifies which CLI arguments should be treated as secret. + +`bytesDecoder` + +The decoder used for converting string values into bytes. + +## Discussion + +// Uses the current process's arguments. +let provider = CommandLineArgumentsProvider() +// Uses custom arguments. +let provider = CommandLineArgumentsProvider(arguments: ["program", "--test", "--port", "8089"]) + +- init(arguments:secretsSpecifier:bytesDecoder:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromhexstringdecoder/configbytesfromstringdecoder-implementations + +- Configuration +- ConfigBytesFromHexStringDecoder +- ConfigBytesFromStringDecoder Implementations + +API Collection + +# ConfigBytesFromStringDecoder Implementations + +## Topics + +### Type Properties + +`static var hex: ConfigBytesFromHexStringDecoder` + +A decoder that interprets string values as hexadecimal-encoded data. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/init(name:initialvalues:) + +#app-main) + +- Configuration +- MutableInMemoryProvider +- init(name:initialValues:) + +Initializer + +# init(name:initialValues:) + +Creates a new mutable in-memory provider with the specified initial values. + +init( +name: String? = nil, +initialValues: [AbsoluteConfigKey : ConfigValue] +) + +MutableInMemoryProvider.swift + +## Parameters + +`name` + +An optional name for the provider, used in debugging and logging. + +`initialValues` + +A dictionary mapping absolute configuration keys to their initial values. + +## Discussion + +This initializer takes a dictionary of absolute configuration keys mapped to their initial values. The provider can be modified after creation using the `setValue(_:forKey:)` methods. + +let key1 = AbsoluteConfigKey(components: ["database", "host"], context: [:]) +let key2 = AbsoluteConfigKey(components: ["database", "port"], context: [:]) + +let provider = MutableInMemoryProvider( +name: "dynamic-config", +initialValues: [\ +key1: "localhost",\ +key2: 5432\ +] +) + +// Later, update values dynamically +provider.setValue("production-db", forKey: key1) + +- init(name:initialValues:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/expressiblebyarrayliteral-implementations + +- Configuration +- ConfigKey +- ExpressibleByArrayLiteral Implementations + +API Collection + +# ExpressibleByArrayLiteral Implementations + +## Topics + +### Initializers + +`init(arrayLiteral: String...)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/configprovider-implementations + +- Configuration +- EnvironmentVariablesProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/context + +- Configuration +- ConfigKey +- context + +Instance Property + +# context + +Additional context information for this configuration key. + +var context: [String : ConfigContextValue] + +ConfigKey.swift + +## Discussion + +Context provides extra information that providers can use to refine lookups or return more specific values. Not all providers use context information. + +## See Also + +### Inspecting a configuration key + +[`var components: [String]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/components) + +The hierarchical components that make up this configuration key. + +- context +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessreporter/report(_:) + +#app-main) + +- Configuration +- AccessReporter +- report(\_:) + +Instance Method + +# report(\_:) + +Processes a configuration access event. + +func report(_ event: AccessEvent) + +AccessReporter.swift + +**Required** + +## Parameters + +`event` + +The configuration access event to process. + +## Discussion + +This method is called whenever a configuration value is accessed through a `ConfigReader` or a `ConfigSnapshotReader`. Implementations should handle events efficiently as they may be called frequently. + +- report(\_:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider/customdebugstringconvertible-implementations + +- Configuration +- CommandLineArgumentsProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.doubleArray(\_:) + +Case + +# ConfigContent.doubleArray(\_:) + +An array of double values. + +case doubleArray([Double]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/expressiblebyextendedgraphemeclusterliteral-implementations + +- Configuration +- ConfigContextValue +- ExpressibleByExtendedGraphemeClusterLiteral Implementations + +API Collection + +# ExpressibleByExtendedGraphemeClusterLiteral Implementations + +## Topics + +### Initializers + +`init(unicodeScalarLiteral: Self.ExtendedGraphemeClusterLiteralType)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/specific(_:) + +#app-main) + +- Configuration +- SecretsSpecifier +- SecretsSpecifier.specific(\_:) + +Case + +# SecretsSpecifier.specific(\_:) + +The library treats the specified keys as secrets. + +SecretsSpecifier.swift + +## Parameters + +`keys` + +The set of keys that should be treated as secrets. + +## Discussion + +Use this case when you have a known set of keys that contain sensitive information. All other keys will be treated as non-secret. + +## See Also + +### Types of specifiers + +`case all` + +The library treats all configuration values as secrets. + +The library determines the secret status dynamically by evaluating each key-value pair. + +`case none` + +The library treats no configuration values as secrets. + +- SecretsSpecifier.specific(\_:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileconfigsnapshot/init(data:providername:parsingoptions:) + +# An unknown error occurred. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider/init(directorypath:allowmissing:secretsspecifier:arrayseparator:) + +#app-main) + +- Configuration +- DirectoryFilesProvider +- init(directoryPath:allowMissing:secretsSpecifier:arraySeparator:) + +Initializer + +# init(directoryPath:allowMissing:secretsSpecifier:arraySeparator:) + +Creates a new provider that reads files from a directory. + +init( +directoryPath: FilePath, +allowMissing: Bool = false, + +arraySeparator: Character = "," +) async throws + +DirectoryFilesProvider.swift + +## Parameters + +`directoryPath` + +The file system path to the directory containing configuration files. + +`allowMissing` + +A flag controlling how the provider handles a missing directory. + +- When `false`, if the directory is missing, throws an error. + +- When `true`, if the directory is missing, treats it as empty. + +`secretsSpecifier` + +Specifies which values should be treated as secrets. + +`arraySeparator` + +The character used to separate elements in array values. + +## Discussion + +This initializer scans the specified directory and loads all regular files as configuration values. Subdirectories are not traversed. Hidden files (starting with a dot) are skipped. + +// Load configuration from a directory +let provider = try await DirectoryFilesProvider( +directoryPath: "/run/secrets" +) + +- init(directoryPath:allowMissing:secretsSpecifier:arraySeparator:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/string + +- Configuration +- ConfigType +- ConfigType.string + +Case + +# ConfigType.string + +A string value. + +case string + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/string(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.string(\_:) + +Case + +# ConfigContent.string(\_:) + +A string value. + +case string(String) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider/configprovider-implementations + +- Configuration +- KeyMappingProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.boolArray(\_:) + +Case + +# ConfigContent.boolArray(\_:) + +An array of Boolean value. + +case boolArray([Bool]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/init(metadata:providerresults:conversionerror:result:) + +#app-main) + +- Configuration +- AccessEvent +- init(metadata:providerResults:conversionError:result:) + +Initializer + +# init(metadata:providerResults:conversionError:result:) + +Creates a configuration access event. + +init( +metadata: AccessEvent.Metadata, +providerResults: [AccessEvent.ProviderResult], +conversionError: (any Error)? = nil, + +AccessReporter.swift + +## Parameters + +`metadata` + +Metadata describing the access operation. + +`providerResults` + +The results from each provider queried. + +`conversionError` + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +`result` + +The final outcome of the access operation. + +## See Also + +### Creating an access event + +`struct Metadata` + +Metadata describing the configuration access operation. + +`struct ProviderResult` + +The result of a configuration lookup from a specific provider. + +- init(metadata:providerResults:conversionError:result:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/setvalue(_:forkey:) + +#app-main) + +- Configuration +- MutableInMemoryProvider +- setValue(\_:forKey:) + +Instance Method + +# setValue(\_:forKey:) + +Updates the stored value for the specified configuration key. + +func setValue( +_ value: ConfigValue?, +forKey key: AbsoluteConfigKey +) + +MutableInMemoryProvider.swift + +## Parameters + +`value` + +The new configuration value, or `nil` to remove the value entirely. + +`key` + +The absolute configuration key to update. + +## Discussion + +This method atomically updates the value and notifies all active watchers of the change. If the new value is the same as the existing value, no notification is sent. + +let provider = MutableInMemoryProvider(initialValues: [:]) +let key = AbsoluteConfigKey(components: ["api", "enabled"], context: [:]) + +// Set a new value +provider.setValue(true, forKey: key) + +// Remove a value +provider.setValue(nil, forKey: key) + +- setValue(\_:forKey:) +- Parameters +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/fileparsingoptions/default + +- Configuration +- FileParsingOptions +- default + +Type Property + +# default + +The default instance of this options type. + +static var `default`: Self { get } + +FileProviderSnapshot.swift + +**Required** + +## Discussion + +This property provides a default configuration that can be used when no parsing options are specified. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/environmentvariablesprovider/init(secretsspecifier:bytesdecoder:arrayseparator:) + +#app-main) + +- Configuration +- EnvironmentVariablesProvider +- init(secretsSpecifier:bytesDecoder:arraySeparator:) + +Initializer + +# init(secretsSpecifier:bytesDecoder:arraySeparator:) + +Creates a new provider that reads from the current process environment. + +init( + +bytesDecoder: some ConfigBytesFromStringDecoder = .base64, +arraySeparator: Character = "," +) + +EnvironmentVariablesProvider.swift + +## Parameters + +`secretsSpecifier` + +Specifies which environment variables should be treated as secrets. + +`bytesDecoder` + +The decoder used for converting string values to byte arrays. + +`arraySeparator` + +The character used to separate elements in array values. + +## Discussion + +This initializer creates a provider that sources configuration values from the environment variables of the current process. + +// Basic usage +let provider = EnvironmentVariablesProvider() + +// With secret handling +let provider = EnvironmentVariablesProvider( +secretsSpecifier: .specific(["API_KEY", "DATABASE_PASSWORD"]) +) + +## See Also + +### Creating an environment variable provider + +Creates a new provider from a custom dictionary of environment variables. + +Creates a new provider that reads from an environment file. + +- init(secretsSpecifier:bytesDecoder:arraySeparator:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/expressiblebystringliteral-implementations + +- Configuration +- ConfigKey +- ExpressibleByStringLiteral Implementations + +API Collection + +# ExpressibleByStringLiteral Implementations + +## Topics + +### Initializers + +`init(extendedGraphemeClusterLiteral: Self.StringLiteralType)` + +`init(stringLiteral: String)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/components + +- Configuration +- ConfigKey +- components + +Instance Property + +# components + +The hierarchical components that make up this configuration key. + +var components: [String] + +ConfigKey.swift + +## Discussion + +Each component represents a level in the configuration hierarchy. For example, `["database", "connection", "timeout"]` represents a three-level nested key. + +## See Also + +### Inspecting a configuration key + +[`var context: [String : ConfigContextValue]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configkey/context) + +Additional context information for this configuration key. + +- components +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/init(_:) + +#app-main) + +- Configuration +- ConfigUpdatesAsyncSequence +- init(\_:) + +Initializer + +# init(\_:) + +Creates a new concrete async sequence wrapping the provided existential sequence. + +AsyncSequences.swift + +## Parameters + +`upstream` + +The async sequence to wrap. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/foundation/uuid + +- Configuration +- Foundation +- UUID + +Extended Structure + +# UUID + +ConfigurationFoundation + +extension UUID + +## Topics + +## Relationships + +### Conforms To + +- `ExpressibleByConfigString` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` + +- UUID +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/customstringconvertible-implementations + +- Configuration +- MutableInMemoryProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfrombase64stringdecoder/init() + +#app-main) + +- Configuration +- ConfigBytesFromBase64StringDecoder +- init() + +Initializer + +# init() + +Creates a new base64 decoder. + +init() + +ConfigBytesFromStringDecoder.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/commandlineargumentsprovider/configprovider-implementations + +- Configuration +- CommandLineArgumentsProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/customstringconvertible-implementations + +- Configuration +- ConfigValue +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/string(forkey:as:issecret:fileid:line:)-4oust + +-4oust#app-main) + +- Configuration +- ConfigReader +- string(forKey:as:isSecret:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve configuration values that can be converted from strings, such as custom types conforming to string conversion protocols. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let serverMode = config.string(forKey: ["server", "mode"], as: ServerMode.self) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:as:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.bytes(\_:) + +Case + +# ConfigContent.bytes(\_:) + +An array of bytes. + +case bytes([UInt8]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/content + +- Configuration +- ConfigValue +- content + +Instance Property + +# content + +The configuration content. + +var content: ConfigContent + +ConfigProvider.swift + +## See Also + +### Inspecting a config value + +`var isSecret: Bool` + +Whether this value contains sensitive information that should not be logged. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/watchsnapshot(fileid:line:updateshandler:) + +#app-main) + +- Configuration +- ConfigReader +- watchSnapshot(fileID:line:updatesHandler:) + +Instance Method + +# watchSnapshot(fileID:line:updatesHandler:) + +Watches the configuration for changes. + +fileID: String = #fileID, +line: UInt = #line, + +ConfigSnapshotReader.swift + +## Parameters + +`fileID` + +The file where this method is called from. + +`line` + +The line where this method is called from. + +`updatesHandler` + +A closure that receives an async sequence of `ConfigSnapshotReader` instances. + +## Return Value + +The value returned by the handler. + +## Discussion + +This method watches the configuration for changes and provides a stream of snapshots to the handler closure. Each snapshot represents the configuration state at a specific point in time. + +try await config.watchSnapshot { snapshots in +for await snapshot in snapshots { +// Process each new configuration snapshot +let cert = snapshot.string(forKey: "cert") +let privateKey = snapshot.string(forKey: "privateKey") +// Ensures that both values are coming from the same underlying snapshot and that a provider +// didn't change its internal state between the two `string(...)` calls. +let newCert = MyCert(cert: cert, privateKey: privateKey) +print("Certificate was updated: \(newCert.redactedDescription)") +} +} + +## See Also + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +- watchSnapshot(fileID:line:updatesHandler:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/configprovider-implementations + +- Configuration +- MutableInMemoryProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/expressiblebybooleanliteral-implementations + +- Configuration +- ConfigValue +- ExpressibleByBooleanLiteral Implementations + +API Collection + +# ExpressibleByBooleanLiteral Implementations + +## Topics + +### Initializers + +`init(booleanLiteral: Bool)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider/configprovider-implementations + +- Configuration +- DirectoryFilesProvider +- ConfigProvider Implementations + +API Collection + +# ConfigProvider Implementations + +## Topics + +### Instance Properties + +`var providerName: String` + +### Instance Methods + +Creates a new configuration provider where each key is rewritten by the given closure. + +Creates a new prefixed configuration provider. + +Implements `watchSnapshot` by getting the current snapshot and emitting it immediately. + +Implements `watchValue` by getting the current value and emitting it immediately. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/int(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.int(\_:) + +Case + +# ConfigContent.int(\_:) + +An integer value. + +case int(Int) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/none + +- Configuration +- SecretsSpecifier +- SecretsSpecifier.none + +Case + +# SecretsSpecifier.none + +The library treats no configuration values as secrets. + +case none + +SecretsSpecifier.swift + +## Discussion + +Use this case when the provider handles only non-sensitive configuration data that can be safely logged or displayed. + +## See Also + +### Types of specifiers + +`case all` + +The library treats all configuration values as secrets. + +The library treats the specified keys as secrets. + +The library determines the secret status dynamically by evaluating each key-value pair. + +- SecretsSpecifier.none +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.byteChunkArray(\_:) + +Case + +# ConfigContent.byteChunkArray(\_:) + +An array of byte arrays. + +case byteChunkArray([[UInt8]]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/issecret + +- Configuration +- ConfigValue +- isSecret + +Instance Property + +# isSecret + +Whether this value contains sensitive information that should not be logged. + +var isSecret: Bool + +ConfigProvider.swift + +## See Also + +### Inspecting a config value + +`var content: ConfigContent` + +The configuration content. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromhexstringdecoder/init() + +#app-main) + +- Configuration +- ConfigBytesFromHexStringDecoder +- init() + +Initializer + +# init() + +Creates a new hexadecimal decoder. + +init() + +ConfigBytesFromStringDecoder.swift + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/expressiblebybooleanliteral-implementations + +- Configuration +- ConfigContextValue +- ExpressibleByBooleanLiteral Implementations + +API Collection + +# ExpressibleByBooleanLiteral Implementations + +## Topics + +### Initializers + +`init(booleanLiteral: Bool)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/result + +- Configuration +- AccessEvent +- result + +Instance Property + +# result + +The final outcome of the configuration access operation. + +AccessReporter.swift + +## Discussion + +## See Also + +### Inspecting an access event + +`var conversionError: (any Error)?` + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +`var metadata: AccessEvent.Metadata` + +Metadata that describes the configuration access operation. + +[`var providerResults: [AccessEvent.ProviderResult]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresults) + +The results from each configuration provider that was queried. + +- result +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/scoped(to:) + +#app-main) + +- Configuration +- ConfigSnapshotReader +- scoped(to:) + +Instance Method + +# scoped(to:) + +Returns a scoped snapshot reader by appending the provided key to the current key prefix. + +ConfigSnapshotReader.swift + +## Parameters + +`configKey` + +The key to append to the current key prefix. + +## Return Value + +A reader for accessing scoped values. + +## Discussion + +Use this method to create a reader that accesses a subset of the configuration. + +let httpConfig = snapshotReader.scoped(to: ["client", "http"]) +let timeout = httpConfig.int(forKey: "timeout") // Reads from "client.http.timeout" in the snapshot + +- scoped(to:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/broadcastingaccessreporter/accessreporter-implementations + +- Configuration +- BroadcastingAccessReporter +- AccessReporter Implementations + +API Collection + +# AccessReporter Implementations + +## Topics + +### Instance Methods + +`func report(AccessEvent)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:as:issecret:fileid:line:)-7bpif + +-7bpif#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:as:isSecret:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve configuration values that can be converted from strings, such as custom types conforming to string conversion protocols. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let serverMode = snapshot.string(forKey: ["server", "mode"], as: ServerMode.self) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:as:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/customstringconvertible-implementations + +- Configuration +- AbsoluteConfigKey +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/keymappingprovider/customstringconvertible-implementations + +- Configuration +- KeyMappingProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/bool(_:) + +#app-main) + +- Configuration +- ConfigContextValue +- ConfigContextValue.bool(\_:) + +Case + +# ConfigContextValue.bool(\_:) + +A Boolean value. + +case bool(Bool) + +ConfigContext.swift + +## See Also + +### Configuration context values + +`case string(String)` + +A string value. + +`case int(Int)` + +An integer value. + +`case double(Double)` + +A floating point value. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader-fetch + +- Configuration +- ConfigReader +- Asynchronously fetching values + +API Collection + +# Asynchronously fetching values + +## Topics + +### Asynchronously fetching string values + +Asynchronously fetches a config value for the given config key. + +Asynchronously fetches a config value for the given config key, with a default fallback. + +Asynchronously fetches a config value for the given config key, converting from string. + +Asynchronously fetches a config value for the given config key with default fallback, converting from string. + +### Asynchronously fetching lists of string values + +Asynchronously fetches an array of config values for the given config key, converting from strings. + +Asynchronously fetches an array of config values for the given config key with default fallback, converting from strings. + +### Asynchronously fetching required string values + +Asynchronously fetches a required config value for the given config key, throwing an error if it’s missing. + +Asynchronously fetches a required config value for the given config key, converting from string. + +### Asynchronously fetching required lists of string values + +Asynchronously fetches a required array of config values for the given config key, converting from strings. + +### Asynchronously fetching Boolean values + +### Asynchronously fetching required Boolean values + +### Asynchronously fetching lists of Boolean values + +### Asynchronously fetching required lists of Boolean values + +### Asynchronously fetching integer values + +### Asynchronously fetching required integer values + +### Asynchronously fetching lists of integer values + +### Asynchronously fetching required lists of integer values + +### Asynchronously fetching double values + +### Asynchronously fetching required double values + +### Asynchronously fetching lists of double values + +### Asynchronously fetching required lists of double values + +### Asynchronously fetching bytes + +### Asynchronously fetching required bytes + +### Asynchronously fetching lists of byte chunks + +### Asynchronously fetching required lists of byte chunks + +## See Also + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +- Asynchronously fetching values +- Topics +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader-watch + +- Configuration +- ConfigReader +- Watching values + +API Collection + +# Watching values + +## Topics + +### Watching string values + +Watches for updates to a config value for the given config key. + +Watches for updates to a config value for the given config key, converting from string. + +Watches for updates to a config value for the given config key with default fallback. + +Watches for updates to a config value for the given config key with default fallback, converting from string. + +### Watching required string values + +Watches for updates to a required config value for the given config key. + +Watches for updates to a required config value for the given config key, converting from string. + +### Watching lists of string values + +Watches for updates to an array of config values for the given config key, converting from strings. + +Watches for updates to an array of config values for the given config key with default fallback, converting from strings. + +### Watching required lists of string values + +Watches for updates to a required array of config values for the given config key, converting from strings. + +### Watching Boolean values + +### Watching required Boolean values + +### Watching lists of Boolean values + +### Watching required lists of Boolean values + +### Watching integer values + +### Watching required integer values + +### Watching lists of integer values + +### Watching required lists of integer values + +### Watching double values + +### Watching required double values + +### Watching lists of double values + +### Watching required lists of double values + +### Watching bytes + +### Watching required bytes + +### Watching lists of byte chunks + +### Watching required lists of byte chunks + +## See Also + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +- Watching values +- Topics +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/yamlsnapshot/parsingoptions/init(bytesdecoder:secretsspecifier:) + +#app-main) + +- Configuration +- YAMLSnapshot +- YAMLSnapshot.ParsingOptions +- init(bytesDecoder:secretsSpecifier:) + +Initializer + +# init(bytesDecoder:secretsSpecifier:) + +Creates custom input configuration for YAML snapshots. + +init( +bytesDecoder: some ConfigBytesFromStringDecoder = .base64, + +) + +YAMLSnapshot.swift + +## Parameters + +`bytesDecoder` + +The decoder to use for converting string values to byte arrays. + +`secretsSpecifier` + +The specifier for identifying secret values. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:as:issecret:default:fileid:line:)-2mphx + +-2mphx#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:as:isSecret:default:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:default:fileID:line:) + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +default defaultValue: Value, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`defaultValue` + +The fallback value returned when the config value is missing or invalid. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The config value if found and convertible, otherwise the default value. + +## Discussion + +Use this method when you need a guaranteed non-nil result for string-convertible types. If the configuration value is missing or can’t be converted to the expected type, the default value is returned instead. + +let serverMode = snapshot.string(forKey: ["server", "mode"], as: ServerMode.self, default: .production) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +- string(forKey:as:isSecret:default:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/conversionerror + +- Configuration +- AccessEvent +- conversionError + +Instance Property + +# conversionError + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +var conversionError: (any Error)? + +AccessReporter.swift + +## See Also + +### Inspecting an access event + +The final outcome of the configuration access operation. + +`var metadata: AccessEvent.Metadata` + +Metadata that describes the configuration access operation. + +[`var providerResults: [AccessEvent.ProviderResult]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresults) + +The results from each configuration provider that was queried. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configbytesfromstringdecoder/decode(_:) + +#app-main) + +- Configuration +- ConfigBytesFromStringDecoder +- decode(\_:) + +Instance Method + +# decode(\_:) + +Decodes a string value into an array of bytes. + +ConfigBytesFromStringDecoder.swift + +**Required** + +## Parameters + +`value` + +The string representation to decode. + +## Return Value + +An array of bytes if decoding succeeds, or `nil` if it fails. + +## Discussion + +This method attempts to parse the provided string according to the decoder’s specific format and returns the corresponding byte array. If the string cannot be decoded (due to invalid format or encoding), the method returns `nil`. + +- decode(\_:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader-get + +- Configuration +- ConfigReader +- Synchronously reading values + +API Collection + +# Synchronously reading values + +## Topics + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +### Synchronously reading lists of string values + +Synchronously gets an array of config values for the given config key, converting from strings. + +Synchronously gets an array of config values for the given config key with default fallback, converting from strings. + +### Synchronously reading required string values + +Synchronously gets a required config value for the given config key, throwing an error if it’s missing. + +Synchronously gets a required config value for the given config key, converting from string. + +### Synchronously reading required lists of string values + +Synchronously gets a required array of config values for the given config key, converting from strings. + +### Synchronously reading Boolean values + +### Synchronously reading required Boolean values + +### Synchronously reading lists of Boolean values + +### Synchronously reading required lists of Boolean values + +### Synchronously reading integer values + +### Synchronously reading required integer values + +### Synchronously reading lists of integer values + +### Synchronously reading required lists of integer values + +### Synchronously reading double values + +### Synchronously reading required double values + +### Synchronously reading lists of double values + +### Synchronously reading required lists of double values + +### Synchronously reading bytes + +### Synchronously reading required bytes + +### Synchronously reading collections of byte chunks + +### Synchronously reading required collections of byte chunks + +## See Also + +### Reading from a snapshot + +Returns a snapshot of the current configuration state. + +Watches the configuration for changes. + +- Synchronously reading values +- Topics +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.intArray(\_:) + +Case + +# ConfigContent.intArray(\_:) + +An array of integer values. + +case intArray([Int]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/foundation/url + +- Configuration +- Foundation +- URL + +Extended Structure + +# URL + +ConfigurationFoundation + +extension URL + +## Topics + +## Relationships + +### Conforms To + +- `ExpressibleByConfigString` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` + +- URL +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/appending(_:) + +#app-main) + +- Configuration +- AbsoluteConfigKey +- appending(\_:) + +Instance Method + +# appending(\_:) + +Returns a new absolute configuration key by appending the given relative key. + +ConfigKey.swift + +## Parameters + +`relative` + +The relative configuration key to append to this key. + +## Return Value + +A new absolute configuration key with the relative key appended. + +- appending(\_:) +- Parameters +- Return Value + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/init(_:issecret:) + +#app-main) + +- Configuration +- ConfigValue +- init(\_:isSecret:) + +Initializer + +# init(\_:isSecret:) + +Creates a new configuration value. + +init( +_ content: ConfigContent, +isSecret: Bool +) + +ConfigProvider.swift + +## Parameters + +`content` + +The configuration content. + +`isSecret` + +Whether the value contains sensitive information. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:issecret:default:fileid:line:) + +#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:isSecret:default:fileID:line:) + +Instance Method + +# string(forKey:isSecret:default:fileID:line:) + +Synchronously gets a config value for the given config key, with a default fallback. + +func string( +forKey key: ConfigKey, +isSecret: Bool = false, +default defaultValue: String, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`defaultValue` + +The fallback value returned when the config value is missing or invalid. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The config value if found and convertible, otherwise the default value. + +## Discussion + +Use this method when you need a guaranteed non-nil result. If the configuration value is missing or can’t be converted to the expected type, the default value is returned instead. + +let maxRetries = snapshot.int(forKey: ["network", "maxRetries"], default: 3) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, converting from string. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:isSecret:default:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/systempackage/filepath + +- Configuration +- SystemPackage +- FilePath + +Extended Structure + +# FilePath + +ConfigurationSystemPackage + +extension FilePath + +## Topics + +## Relationships + +### Conforms To + +- `ExpressibleByConfigString` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` + +- FilePath +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/expressiblebyconfigstring/init(configstring:) + +#app-main) + +- Configuration +- ExpressibleByConfigString +- init(configString:) + +Initializer + +# init(configString:) + +Creates an instance from a configuration string value. + +init?(configString: String) + +ExpressibleByConfigString.swift + +**Required** + +## Parameters + +`configString` + +The string value from the configuration provider. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/metadata-swift.struct + +- Configuration +- AccessEvent +- AccessEvent.Metadata + +Structure + +# AccessEvent.Metadata + +Metadata describing the configuration access operation. + +struct Metadata + +AccessReporter.swift + +## Overview + +Contains information about the type of access, the key accessed, value type, source location, and timestamp. + +## Topics + +### Creating access event metadata + +`init(accessKind: AccessEvent.Metadata.AccessKind, key: AbsoluteConfigKey, valueType: ConfigType, sourceLocation: AccessEvent.Metadata.SourceLocation, accessTimestamp: Date)` + +Creates access event metadata. + +`enum AccessKind` + +The type of configuration access operation. + +### Inspecting access event metadata + +`var accessKind: AccessEvent.Metadata.AccessKind` + +The type of configuration access operation for this event. + +`var accessTimestamp: Date` + +The timestamp when the configuration access occurred. + +`var key: AbsoluteConfigKey` + +The configuration key accessed. + +`var sourceLocation: AccessEvent.Metadata.SourceLocation` + +The source code location where the access occurred. + +`var valueType: ConfigType` + +The expected type of the configuration value. + +### Structures + +`struct SourceLocation` + +The source code location where a configuration access occurred. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating an access event + +Creates a configuration access event. + +`struct ProviderResult` + +The result of a configuration lookup from a specific provider. + +- AccessEvent.Metadata +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/broadcastingaccessreporter/init(upstreams:) + +#app-main) + +- Configuration +- BroadcastingAccessReporter +- init(upstreams:) + +Initializer + +# init(upstreams:) + +Creates a new broadcasting access reporter. + +init(upstreams: [any AccessReporter]) + +AccessReporter.swift + +## Parameters + +`upstreams` + +The reporters that will receive forwarded events. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/expressiblebyextendedgraphemeclusterliteral-implementations + +- Configuration +- ConfigValue +- ExpressibleByExtendedGraphemeClusterLiteral Implementations + +API Collection + +# ExpressibleByExtendedGraphemeClusterLiteral Implementations + +## Topics + +### Initializers + +`init(unicodeScalarLiteral: Self.ExtendedGraphemeClusterLiteralType)` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/customstringconvertible-implementations + +- Configuration +- ConfigContextValue +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/init(provider:accessreporter:) + +#app-main) + +- Configuration +- ConfigReader +- init(provider:accessReporter:) + +Initializer + +# init(provider:accessReporter:) + +Creates a config reader with a single provider. + +init( +provider: some ConfigProvider, +accessReporter: (any AccessReporter)? = nil +) + +ConfigReader.swift + +## Parameters + +`provider` + +The configuration provider. + +`accessReporter` + +The reporter for configuration access events. + +## See Also + +### Creating config readers + +[`init(providers: [any ConfigProvider], accessReporter: (any AccessReporter)?)`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/init(providers:accessreporter:)) + +Creates a config reader with multiple providers. + +- init(provider:accessReporter:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/prepending(_:) + +# An unknown error occurred. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/int + +- Configuration +- ConfigType +- ConfigType.int + +Case + +# ConfigType.int + +An integer value. + +case int + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configvalue/equatable-implementations + +- Configuration +- ConfigValue +- Equatable Implementations + +API Collection + +# Equatable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/string(_:) + +#app-main) + +- Configuration +- ConfigContextValue +- ConfigContextValue.string(\_:) + +Case + +# ConfigContextValue.string(\_:) + +A string value. + +case string(String) + +ConfigContext.swift + +## See Also + +### Configuration context values + +`case bool(Bool)` + +A Boolean value. + +`case int(Int)` + +An integer value. + +`case double(Double)` + +A floating point value. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/stringarray + +- Configuration +- ConfigType +- ConfigType.stringArray + +Case + +# ConfigType.stringArray + +An array of string values. + +case stringArray + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/stringarray(forkey:issecret:fileid:line:) + +#app-main) + +- Configuration +- ConfigSnapshotReader +- stringArray(forKey:isSecret:fileID:line:) + +Instance Method + +# stringArray(forKey:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key. + +func stringArray( +forKey key: ConfigKey, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve optional configuration values from a snapshot. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let port = snapshot.int(forKey: ["server", "port"]) + +## See Also + +### Synchronously reading lists of string values + +Synchronously gets an array of config values for the given config key, converting from strings. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets an array of config values for the given config key with default fallback, converting from strings. + +- stringArray(forKey:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/components + +- Configuration +- AbsoluteConfigKey +- components + +Instance Property + +# components + +The hierarchical components that make up this absolute configuration key. + +var components: [String] + +ConfigKey.swift + +## Discussion + +Each component represents a level in the configuration hierarchy, forming a complete path from the root of the configuration structure. + +## See Also + +### Inspecting an absolute configuration key + +[`var context: [String : ConfigContextValue]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/context) + +Additional context information for this configuration key. + +- components +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/bool + +- Configuration +- ConfigType +- ConfigType.bool + +Case + +# ConfigType.bool + +A Boolean value. + +case bool + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/dynamic(_:) + +#app-main) + +- Configuration +- SecretsSpecifier +- SecretsSpecifier.dynamic(\_:) + +Case + +# SecretsSpecifier.dynamic(\_:) + +The library determines the secret status dynamically by evaluating each key-value pair. + +SecretsSpecifier.swift + +## Parameters + +`closure` + +A closure that takes a key and value and returns whether the value should be treated as secret. + +## Discussion + +Use this case when you need complex logic to determine whether a value is secret based on the key name, value content, or other criteria. + +## See Also + +### Types of specifiers + +`case all` + +The library treats all configuration values as secrets. + +The library treats the specified keys as secrets. + +`case none` + +The library treats no configuration values as secrets. + +- SecretsSpecifier.dynamic(\_:) +- Parameters +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/all + +- Configuration +- SecretsSpecifier +- SecretsSpecifier.all + +Case + +# SecretsSpecifier.all + +The library treats all configuration values as secrets. + +case all + +SecretsSpecifier.swift + +## Discussion + +Use this case when the provider exclusively handles sensitive information and all values should be protected from disclosure. + +## See Also + +### Types of specifiers + +The library treats the specified keys as secrets. + +The library determines the secret status dynamically by evaluating each key-value pair. + +`case none` + +The library treats no configuration values as secrets. + +- SecretsSpecifier.all +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting/providercompattest/testconfiguration + +- ConfigurationTesting +- ProviderCompatTest +- ProviderCompatTest.TestConfiguration + +Structure + +# ProviderCompatTest.TestConfiguration + +Configuration options for customizing test behavior. + +struct TestConfiguration + +ProviderCompatTest.swift + +## Topics + +### Initializers + +[`init(overrides: [String : ConfigContent])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting/providercompattest/testconfiguration/init(overrides:)) + +Creates a new test configuration. + +### Instance Properties + +[`var overrides: [String : ConfigContent]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configurationtesting/providercompattest/testconfiguration/overrides) + +Value overrides for testing custom scenarios. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +- ProviderCompatTest.TestConfiguration +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/foundation/date + +- Configuration +- Foundation +- Date + +Extended Structure + +# Date + +ConfigurationFoundation + +extension Date + +## Topics + +## Relationships + +### Conforms To + +- `ExpressibleByConfigString` +- `Swift.Copyable` +- `Swift.CustomStringConvertible` + +- Date +- Topics +- Relationships + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/boolarray + +- Configuration +- ConfigType +- ConfigType.boolArray + +Case + +# ConfigType.boolArray + +An array of Boolean values. + +case boolArray + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case int` + +An integer value. + +`case intArray` + +An array of integer values. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/equatable-implementations + +- Configuration +- ConfigContextValue +- Equatable Implementations + +API Collection + +# Equatable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/init(_:) + +#app-main) + +- Configuration +- AbsoluteConfigKey +- init(\_:) + +Initializer + +# init(\_:) + +Creates a new absolute configuration key from a relative key. + +init(_ relative: ConfigKey) + +ConfigKey.swift + +## Parameters + +`relative` + +The relative configuration key to convert. + +## See Also + +### Creating an absolute configuration key + +[`init([String], context: [String : ConfigContextValue])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/init(_:context:)) + +Creates a new absolute configuration key. + +- init(\_:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/asyncsequence-implementations + +- Configuration +- ConfigUpdatesAsyncSequence +- AsyncSequence Implementations + +API Collection + +# AsyncSequence Implementations + +## Topics + +### Instance Methods + +[`func chunked(by: AsyncTimerSequence) -> AsyncChunksOfCountOrSignalSequence>`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/chunked(by:)-trjw) + +`func chunked(by: AsyncTimerSequence, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence>` + +[`func chunks(ofCount: Int, or: AsyncTimerSequence) -> AsyncChunksOfCountOrSignalSequence>`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/chunks(ofcount:or:)-8u4c4) + +`func chunks(ofCount: Int, or: AsyncTimerSequence, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence>` + +`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/makeasynciterator()) + +`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configupdatesasyncsequence/share(bufferingpolicy:)) + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshot/value(forkey:type:) + +#app-main) + +- Configuration +- ConfigSnapshot +- value(forKey:type:) + +Instance Method + +# value(forKey:type:) + +Returns a value for the specified key from this immutable snapshot. + +func value( +forKey key: AbsoluteConfigKey, +type: ConfigType + +ConfigProvider.swift + +**Required** + +## Parameters + +`key` + +The configuration key to look up. + +`type` + +The expected configuration value type. + +## Return Value + +The lookup result containing the value and encoded key, or nil if not found. + +## Discussion + +Unlike `value(forKey:type:)`, this method always returns the same value for identical parameters because the snapshot represents a fixed point in time. Values can be accessed synchronously and efficiently. + +## See Also + +### Required methods + +`var providerName: String` + +The human-readable name of the configuration provider that created this snapshot. + +- value(forKey:type:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configtype/intarray + +- Configuration +- ConfigType +- ConfigType.intArray + +Case + +# ConfigType.intArray + +An array of integer values. + +case intArray + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string` + +A string value. + +`case stringArray` + +An array of string values. + +`case bool` + +A Boolean value. + +`case boolArray` + +An array of Boolean values. + +`case int` + +An integer value. + +`case double` + +A double value. + +`case doubleArray` + +An array of double values. + +`case bytes` + +An array of bytes. + +`case byteChunkArray` + +An array of byte chunks. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/double(_:) + +#app-main) + +- Configuration +- ConfigContextValue +- ConfigContextValue.double(\_:) + +Case + +# ConfigContextValue.double(\_:) + +A floating point value. + +case double(Double) + +ConfigContext.swift + +## See Also + +### Configuration context values + +`case string(String)` + +A string value. + +`case bool(Bool)` + +A Boolean value. + +`case int(Int)` + +An integer value. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider/customstringconvertible-implementations + +- Configuration +- DirectoryFilesProvider +- CustomStringConvertible Implementations + +API Collection + +# CustomStringConvertible Implementations + +## Topics + +### Instance Properties + +`var description: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontextvalue/int(_:) + +#app-main) + +- Configuration +- ConfigContextValue +- ConfigContextValue.int(\_:) + +Case + +# ConfigContextValue.int(\_:) + +An integer value. + +case int(Int) + +ConfigContext.swift + +## See Also + +### Configuration context values + +`case string(String)` + +A string value. + +`case bool(Bool)` + +A Boolean value. + +`case double(Double)` + +A floating point value. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/comparable-implementations + +- Configuration +- AbsoluteConfigKey +- Comparable Implementations + +API Collection + +# Comparable Implementations + +## Topics + +### Operators + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configreader/init(providers:accessreporter:) + +#app-main) + +- Configuration +- ConfigReader +- init(providers:accessReporter:) + +Initializer + +# init(providers:accessReporter:) + +Creates a config reader with multiple providers. + +init( +providers: [any ConfigProvider], +accessReporter: (any AccessReporter)? = nil +) + +ConfigReader.swift + +## Parameters + +`providers` + +The configuration providers, queried in order until a value is found. + +`accessReporter` + +The reporter for configuration access events. + +## See Also + +### Creating config readers + +`init(provider: some ConfigProvider, accessReporter: (any AccessReporter)?)` + +Creates a config reader with a single provider. + +- init(providers:accessReporter:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:as:issecret:default:fileid:line:)-fzpe + +-fzpe#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:as:isSecret:default:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:default:fileID:line:) + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +default defaultValue: Value, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`defaultValue` + +The fallback value returned when the config value is missing or invalid. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The config value if found and convertible, otherwise the default value. + +## Discussion + +Use this method when you need a guaranteed non-nil result for string-convertible types. If the configuration value is missing or can’t be converted to the expected type, the default value is returned instead. + +let serverMode = snapshot.string(forKey: ["server", "mode"], as: ServerMode.self, default: .production) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +- string(forKey:as:isSecret:default:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/absoluteconfigkey/init(_:context:) + +#app-main) + +- Configuration +- AbsoluteConfigKey +- init(\_:context:) + +Initializer + +# init(\_:context:) + +Creates a new absolute configuration key. + +init( +_ components: [String], +context: [String : ConfigContextValue] = [:] +) + +ConfigKey.swift + +## Parameters + +`components` + +The hierarchical components that make up the complete key path. + +`context` + +Additional context information for the key. + +## See Also + +### Creating an absolute configuration key + +`init(ConfigKey)` + +Creates a new absolute configuration key from a relative key. + +- init(\_:context:) +- Parameters +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/mutableinmemoryprovider/customdebugstringconvertible-implementations + +- Configuration +- MutableInMemoryProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresult + +- Configuration +- AccessEvent +- AccessEvent.ProviderResult + +Structure + +# AccessEvent.ProviderResult + +The result of a configuration lookup from a specific provider. + +struct ProviderResult + +AccessReporter.swift + +## Overview + +Contains the provider’s name and the outcome of querying that provider, which can be either a successful lookup result or an error. + +## Topics + +### Creating provider results + +Creates a provider result. + +### Inspecting provider results + +The outcome of the configuration lookup operation. + +`var providerName: String` + +The name of the configuration provider that processed the lookup. + +## Relationships + +### Conforms To + +- `Swift.Sendable` +- `Swift.SendableMetatype` + +## See Also + +### Creating an access event + +Creates a configuration access event. + +`struct Metadata` + +Metadata describing the configuration access operation. + +- AccessEvent.ProviderResult +- Overview +- Topics +- Relationships +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:issecret:fileid:line:) + +#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:isSecret:fileID:line:) + +Instance Method + +# string(forKey:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key. + +func string( +forKey key: ConfigKey, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve optional configuration values from a snapshot. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let port = snapshot.int(forKey: ["server", "port"]) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key, converting from string. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/double(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.double(\_:) + +Case + +# ConfigContent.double(\_:) + +A double value. + +case double(Double) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +[`case stringArray([String])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:)) + +An array of string values. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/stringarray(_:) + +#app-main) + +- Configuration +- ConfigContent +- ConfigContent.stringArray(\_:) + +Case + +# ConfigContent.stringArray(\_:) + +An array of string values. + +case stringArray([String]) + +ConfigProvider.swift + +## See Also + +### Types of configuration content + +`case string(String)` + +A string value. + +`case bool(Bool)` + +A Boolean value. + +[`case boolArray([Bool])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/boolarray(_:)) + +An array of Boolean value. + +`case int(Int)` + +An integer value. + +[`case intArray([Int])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/intarray(_:)) + +An array of integer values. + +`case double(Double)` + +A double value. + +[`case doubleArray([Double])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/doublearray(_:)) + +An array of double values. + +[`case bytes([UInt8])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytes(_:)) + +An array of bytes. + +[`case byteChunkArray([[UInt8]])`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configcontent/bytechunkarray(_:)) + +An array of byte arrays. + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshotreader/string(forkey:as:issecret:fileid:line:)-8hlcf + +-8hlcf#app-main) + +- Configuration +- ConfigSnapshotReader +- string(forKey:as:isSecret:fileID:line:) + +Instance Method + +# string(forKey:as:isSecret:fileID:line:) + +Synchronously gets a config value for the given config key, converting from string. + +forKey key: ConfigKey, +as type: Value.Type = Value.self, +isSecret: Bool = false, +fileID: String = #fileID, +line: UInt = #line + +ConfigSnapshotReader+methods.swift + +## Parameters + +`key` + +The config key to look up. + +`type` + +The type to convert the string value to. + +`isSecret` + +Whether the value should be treated as secret for logging and debugging purposes. + +`fileID` + +The file ID where this call originates. Used for access reporting. + +`line` + +The line number where this call originates. Used for access reporting. + +## Return Value + +The value converted to the expected type if found and convertible, otherwise `nil`. + +## Discussion + +Use this method to retrieve configuration values that can be converted from strings, such as custom types conforming to string conversion protocols. If the value doesn’t exist or can’t be converted to the expected type, the method returns `nil`. + +let serverMode = snapshot.string(forKey: ["server", "mode"], as: ServerMode.self) + +## See Also + +### Synchronously reading string values + +Synchronously gets a config value for the given config key. + +Synchronously gets a config value for the given config key, with a default fallback. + +Synchronously gets a config value for the given config key with default fallback, converting from string. + +- string(forKey:as:isSecret:fileID:line:) +- Parameters +- Return Value +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/directoryfilesprovider/customdebugstringconvertible-implementations + +- Configuration +- DirectoryFilesProvider +- CustomDebugStringConvertible Implementations + +API Collection + +# CustomDebugStringConvertible Implementations + +## Topics + +### Instance Properties + +`var debugDescription: String` + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/configsnapshot/providername + +- Configuration +- ConfigSnapshot +- providerName + +Instance Property + +# providerName + +The human-readable name of the configuration provider that created this snapshot. + +var providerName: String { get } + +ConfigProvider.swift + +**Required** + +## Discussion + +Used by `AccessReporter` and when diagnostic logging the config reader types. + +## See Also + +### Required methods + +Returns a value for the specified key from this immutable snapshot. + +- providerName +- Discussion +- See Also + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/secretsspecifier/issecret(key:value:) + +#app-main) + +- Configuration +- SecretsSpecifier +- isSecret(key:value:) + +Instance Method + +# isSecret(key:value:) + +Determines whether a configuration value should be treated as secret. + +func isSecret( +key: KeyType, +value: ValueType + +SecretsSpecifier.swift + +Available when `KeyType` conforms to `Hashable`, `KeyType` conforms to `Sendable`, and `ValueType` conforms to `Sendable`. + +## Parameters + +`key` + +The provider-specific configuration key. + +`value` + +The configuration value to evaluate. + +## Return Value + +`true` if the value should be treated as secret; otherwise, `false`. + +## Discussion + +This method evaluates the secrets specifier against the provided key-value pair to determine if the value contains sensitive information that should be protected from disclosure. + +let isSecret = specifier.isSecret(key: "API_KEY", value: "secret123") +// Returns: true + +- isSecret(key:value:) +- Parameters +- Return Value +- Discussion + +| +| + +--- + +# https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/metadata-swift.property + +- Configuration +- AccessEvent +- metadata + +Instance Property + +# metadata + +Metadata that describes the configuration access operation. + +var metadata: AccessEvent.Metadata + +AccessReporter.swift + +## See Also + +### Inspecting an access event + +The final outcome of the configuration access operation. + +`var conversionError: (any Error)?` + +An error that occurred when converting the raw config value into another type, for example `RawRepresentable`. + +[`var providerResults: [AccessEvent.ProviderResult]`](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration/accessevent/providerresults) + +The results from each configuration provider that was queried. + +| +| + +--- + diff --git a/Examples/CelestraCloud/.devcontainer/devcontainer.json b/Examples/CelestraCloud/.devcontainer/devcontainer.json new file mode 100644 index 00000000..6fed9bab --- /dev/null +++ b/Examples/CelestraCloud/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.2", + "image": "swift:6.2", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "sswg.swift-lang" + ] + } + }, + "remoteUser": "root" +} \ No newline at end of file diff --git a/Examples/CelestraCloud/.devcontainer/swift-6.2-nightly/devcontainer.json b/Examples/CelestraCloud/.devcontainer/swift-6.2-nightly/devcontainer.json new file mode 100644 index 00000000..b5bd73c4 --- /dev/null +++ b/Examples/CelestraCloud/.devcontainer/swift-6.2-nightly/devcontainer.json @@ -0,0 +1,40 @@ +{ + "name": "Swift 6.2 Nightly", + "image": "swiftlang/swift:nightly-6.2-noble", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "sswg.swift-lang" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "root" +} \ No newline at end of file diff --git a/Examples/CelestraCloud/.devcontainer/swift-6.2/devcontainer.json b/Examples/CelestraCloud/.devcontainer/swift-6.2/devcontainer.json new file mode 100644 index 00000000..6fed9bab --- /dev/null +++ b/Examples/CelestraCloud/.devcontainer/swift-6.2/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.2", + "image": "swift:6.2", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "sswg.swift-lang" + ] + } + }, + "remoteUser": "root" +} \ No newline at end of file diff --git a/Examples/CelestraCloud/.devcontainer/swift-6.3-nightly/devcontainer.json b/Examples/CelestraCloud/.devcontainer/swift-6.3-nightly/devcontainer.json new file mode 100644 index 00000000..09cd93fb --- /dev/null +++ b/Examples/CelestraCloud/.devcontainer/swift-6.3-nightly/devcontainer.json @@ -0,0 +1,40 @@ +{ + "name": "Swift 6.3 Nightly", + "image": "swiftlang/swift:nightly-6.3-noble", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "sswg.swift-lang" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "root" +} diff --git a/Examples/CelestraCloud/.env.example b/Examples/CelestraCloud/.env.example new file mode 100644 index 00000000..243217fb --- /dev/null +++ b/Examples/CelestraCloud/.env.example @@ -0,0 +1,37 @@ +# CloudKit Configuration +# Copy this file to .env and fill in your values + +# Your CloudKit container ID (e.g., iCloud.com.brightdigit.Celestra) +CLOUDKIT_CONTAINER_ID=iCloud.com.brightdigit.Celestra + +# Your CloudKit server-to-server key ID from Apple Developer Console +CLOUDKIT_KEY_ID=your-key-id-here + +# Path to your CloudKit private key PEM file +CLOUDKIT_PRIVATE_KEY_PATH=/path/to/eckey.pem + +# CloudKit environment: development or production +CLOUDKIT_ENVIRONMENT=development + +# Update Command Configuration (Optional) +# These settings control RSS feed update behavior + +# Delay between feed updates in seconds (default: 2.0) +# Respects web etiquette by spacing out requests +UPDATE_DELAY=2.0 + +# Skip robots.txt checking (default: false) +# Set to true to bypass robots.txt validation +# UPDATE_SKIP_ROBOTS_CHECK=true + +# Maximum consecutive failures before skipping a feed (no default) +# Feeds with more failures than this threshold are skipped +# UPDATE_MAX_FAILURES=5 + +# Minimum subscriber count to update a feed (no default) +# Only update feeds with at least this many subscribers +# UPDATE_MIN_POPULARITY=10 + +# Only update feeds last attempted before this date (no default) +# Format: ISO8601 (e.g., 2025-01-01T00:00:00Z) +# UPDATE_LAST_ATTEMPTED_BEFORE=2025-01-01T00:00:00Z diff --git a/Examples/CelestraCloud/.github/SECRETS_SETUP.md b/Examples/CelestraCloud/.github/SECRETS_SETUP.md new file mode 100644 index 00000000..6100f601 --- /dev/null +++ b/Examples/CelestraCloud/.github/SECRETS_SETUP.md @@ -0,0 +1,188 @@ +# GitHub Secrets Setup Guide + +This document describes the GitHub secrets required for the automated RSS feed update workflow. + +## Required Secrets + +The workflow needs **2 repository secrets** to authenticate with CloudKit using Server-to-Server authentication. A third secret (`CLOUDKIT_CONTAINER_ID`) is optional since the default value is configured in the code. + +### Where to Add Secrets + +1. Go to your repository on GitHub +2. Navigate to: **Settings** → **Secrets and variables** → **Actions** +3. Click **New repository secret** for each secret below + +Direct link: https://github.com/brightdigit/CelestraCloud/settings/secrets/actions + +--- + +## 1. CLOUDKIT_CONTAINER_ID (Optional) + +**Name:** `CLOUDKIT_CONTAINER_ID` + +**Status:** **Optional** - The default value `iCloud.com.brightdigit.Celestra` is configured in the code. + +**Value (if overriding default):** +``` +iCloud.com.brightdigit.Celestra +``` + +**Description:** The CloudKit container identifier for the Celestra app. This is the same for both development and production environments. Only set this secret if you need to use a different container. + +--- + +## 2. CLOUDKIT_KEY_ID + +**Name:** `CLOUDKIT_KEY_ID` + +**Value:** Your CloudKit Server-to-Server key ID from Apple Developer Console + +**Format:** Alphanumeric string (e.g., `ABC123XYZ456`) + +**How to obtain:** +1. Go to [Apple Developer Console](https://developer.apple.com/account) +2. Navigate to: **Certificates, Identifiers & Profiles** → **Keys** +3. Find your CloudKit Server-to-Server key (or create one if needed) +4. Copy the **Key ID** value + +**Creating a new key (if needed):** +1. Click the **+** button to create a new key +2. Enter a name (e.g., "CelestraCloud Server-to-Server") +3. Check **CloudKit** +4. Click **Continue** → **Register** → **Download** +5. **IMPORTANT:** Save the downloaded `.p8` file - you'll need to convert it to PEM format +6. Copy the **Key ID** shown on the confirmation page + +--- + +## 3. CLOUDKIT_PRIVATE_KEY + +**Name:** `CLOUDKIT_PRIVATE_KEY` + +**Value:** The full contents of your PEM-formatted private key file + +**Format:** Multi-line text starting with `-----BEGIN EC PRIVATE KEY-----` and ending with `-----END EC PRIVATE KEY-----` + +**Example:** +``` +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIB1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP +qrstuvwxyz1234567890+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn +opqrstuvwxyz1234567890+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijk +... +-----END EC PRIVATE KEY----- +``` + +**How to obtain:** + +### If you have the `.p8` file from Apple: + +Convert it to PEM format: + +```bash +# Convert .p8 to PEM format +openssl ec -in AuthKey_YOUR_KEY_ID.p8 -out cloudkit_key.pem + +# View the PEM file contents +cat cloudkit_key.pem +``` + +Then copy the **entire output** (including BEGIN/END lines) and paste it as the secret value. + +### If you already have the PEM file: + +```bash +# Display the PEM file contents +cat /path/to/your/cloudkit_key.pem +``` + +Copy the entire output and paste it as the secret value. + +### Important Notes: +- Include the `-----BEGIN EC PRIVATE KEY-----` and `-----END EC PRIVATE KEY-----` lines +- Include all line breaks and formatting exactly as shown +- Do not add any extra spaces or modify the format +- GitHub will encrypt this value automatically + +--- + +## Environment Configuration + +The workflow uses these secrets for **both** CloudKit environments: +- **Development**: Used for manual testing (default) +- **Production**: Used for scheduled runs + +The same key works for both environments. The environment is selected at runtime via the workflow input or automatically for scheduled runs. + +--- + +## Verification + +After adding the required secrets (at minimum `CLOUDKIT_KEY_ID` and `CLOUDKIT_PRIVATE_KEY`), you can verify the setup by running the workflow manually: + +```bash +# Trigger a test run using development environment +gh workflow run update-feeds.yml \ + --ref 19-scheduled-job \ + -f tier=high \ + -f environment=development \ + -f delay=2.0 +``` + +Or through the GitHub UI: +1. Go to **Actions** tab +2. Select **Update RSS Feeds** workflow +3. Click **Run workflow** +4. Select: + - Branch: `19-scheduled-job` + - Tier: `high` (quick test) + - Environment: `development` (safe testing) + - Delay: `2.0` (default) +5. Click **Run workflow** + +--- + +## Troubleshooting + +### "Invalid credentials" or authentication errors +- Verify `CLOUDKIT_KEY_ID` matches the key in Apple Developer Console +- Ensure `CLOUDKIT_PRIVATE_KEY` includes the BEGIN/END lines +- Check for extra spaces or formatting issues in the PEM key + +### "Container not found" errors +- Verify `CLOUDKIT_CONTAINER_ID` is spelled correctly +- Ensure the container exists in Apple Developer Console + +### "Permission denied" errors +- Verify the Server-to-Server key has CloudKit permissions enabled +- Check that the key hasn't been revoked in Apple Developer Console + +--- + +## Security Notes + +- ✅ Secrets are encrypted by GitHub and only accessible to workflow runs +- ✅ The private key is created in `/tmp` during workflow execution and deleted after +- ✅ File permissions are set to `600` (owner read/write only) +- ✅ The key is never logged or exposed in workflow outputs +- ✅ Consider rotating keys periodically for security best practices + +--- + +## Secret Rotation + +If you need to rotate your CloudKit key: + +1. **Generate new key** in Apple Developer Console +2. **Convert to PEM format** (if needed) +3. **Update GitHub secrets** with new values +4. **Test the workflow** to verify the new credentials work +5. **Revoke old key** in Apple Developer Console (optional, but recommended) + +--- + +## Additional Resources + +- [CloudKit Server-to-Server Authentication](https://developer.apple.com/documentation/cloudkit/ckservertoclientoperation) +- [GitHub Actions Encrypted Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) +- [MistKit CloudKit Integration](https://github.com/brightdigit/MistKit) diff --git a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml new file mode 100644 index 00000000..f12be024 --- /dev/null +++ b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml @@ -0,0 +1,187 @@ +name: CelestraCloud +on: + push: + branches-ignore: + - '*WIP' +env: + PACKAGE_NAME: CelestraCloud +jobs: + build-ubuntu: + name: Build on Ubuntu + runs-on: ubuntu-latest + container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + matrix: + os: [noble, jammy] + swift: + - version: "6.2" # Uses Swift 6.2.3 release - works fine + - version: "6.3" + nightly: true + + steps: + - uses: actions/checkout@v4 + + - name: Update Package.swift to use remote MistKit branch + run: | + sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "main")|g' Package.swift + rm -f Package.resolved + + - uses: brightdigit/swift-build@v1.4.2 + with: + skip-package-resolved: true + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && 'nightly' || '' }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-windows: + name: Build on Windows + runs-on: ${{ matrix.runs-on }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + matrix: + runs-on: [windows-2022, windows-2025] + swift: + - version: swift-6.3-branch + build: 6.3-DEVELOPMENT-SNAPSHOT-2025-12-21-a + steps: + - uses: actions/checkout@v4 + - name: Update Package.swift to use remote MistKit branch + run: | + (Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', '.package(url: "https://github.com/brightdigit/MistKit.git", branch: "main")' | Set-Content Package.swift + Remove-Item -Path Package.resolved -Force -ErrorAction SilentlyContinue + - uses: brightdigit/swift-build@v1.4.2 + with: + windows-swift-version: ${{ matrix.swift.version }} + windows-swift-build: ${{ matrix.swift.build }} + skip-package-resolved: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift.version }},windows + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + os: windows + + build-macos: + name: Build on macOS + env: + PACKAGE_NAME: CelestraCloud + runs-on: ${{ matrix.runs-on }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + fail-fast: false + matrix: + include: + # SPM Build Matrix + - runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + + # macOS Build Matrix + - type: macos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + + # iOS Build + - type: ios + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + deviceName: "iPhone 17 Pro" + osVersion: "26.2" + download-platform: true + + + # watchOS Build Matrix + - type: watchos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + deviceName: "Apple Watch Ultra 3 (49mm)" + osVersion: "26.2" + download-platform: true + + # tvOS Build + - type: tvos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + deviceName: "Apple TV" + osVersion: "26.2" + download-platform: true + + # visionOS Build + - type: visionos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.2.app" + deviceName: "Apple Vision Pro" + osVersion: "26.2" + download-platform: true + + steps: + - uses: actions/checkout@v4 + + - name: Update Package.swift to use remote MistKit branch + run: | + sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "main")|g' Package.swift + rm -f Package.resolved + + - name: Build and Test + uses: brightdigit/swift-build@v1.4.2 + with: + scheme: ${{ env.PACKAGE_NAME }}-Package + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + skip-package-resolved: true + + # Common Coverage Steps + - name: Process Coverage + uses: sersoft-gmbh/swift-coverage-action@v4 + + - name: Upload Coverage + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + lint: + name: Linting + if: "!contains(github.event.head_commit.message, 'ci skip')" + runs-on: ubuntu-latest + needs: [build-ubuntu, build-windows, build-macos] + env: + MINT_PATH: .mint/lib + MINT_LINK_PATH: .mint/bin + steps: + - uses: actions/checkout@v4 + - name: Cache mint + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache + with: + path: | + .mint + Mint + key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} + restore-keys: | + ${{ runner.os }}-mint- + - name: Install mint + if: steps.cache-mint.outputs.cache-hit == '' + run: | + git clone https://github.com/yonaskolb/Mint.git + cd Mint + swift run mint install yonaskolb/mint + - name: Lint + run: | + set -e + ./Scripts/lint.sh diff --git a/Examples/CelestraCloud/.github/workflows/claude-code-review.yml b/Examples/CelestraCloud/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..8452b0f2 --- /dev/null +++ b/Examples/CelestraCloud/.github/workflows/claude-code-review.yml @@ -0,0 +1,57 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + diff --git a/Examples/CelestraCloud/.github/workflows/claude.yml b/Examples/CelestraCloud/.github/workflows/claude.yml new file mode 100644 index 00000000..d300267f --- /dev/null +++ b/Examples/CelestraCloud/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/Examples/CelestraCloud/.github/workflows/codeql.yml b/Examples/CelestraCloud/.github/workflows/codeql.yml new file mode 100644 index 00000000..05d6ef6d --- /dev/null +++ b/Examples/CelestraCloud/.github/workflows/codeql.yml @@ -0,0 +1,87 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches-ignore: + - '*WIP' + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '20 11 * * 3' + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'swift' ] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Xcode + run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + + - name: Verify Swift Version + run: | + swift --version + swift package --version + + - name: Update Package.swift to use remote MistKit branch + run: | + sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "main")|g' Package.swift + rm -f Package.resolved + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - run: | + echo "Run, Build Application using script" + swift build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/Examples/CelestraCloud/.github/workflows/update-feeds.yml b/Examples/CelestraCloud/.github/workflows/update-feeds.yml new file mode 100644 index 00000000..8333e441 --- /dev/null +++ b/Examples/CelestraCloud/.github/workflows/update-feeds.yml @@ -0,0 +1,580 @@ +name: Update RSS Feeds + +on: + # Conservative schedule for testing + schedule: + # Standard feeds (daily at 2 AM UTC) + - cron: '0 2 * * *' + + # Stale feeds (weekly on Sundays at 3 AM UTC) + - cron: '0 3 * * 0' + + # Pull request testing + pull_request: + branches: [main] + + # Manual trigger for testing + workflow_dispatch: + inputs: + tier: + description: 'Feed tier to update (high/standard/stale/all)' + required: false + default: 'all' + type: choice + options: + - high + - standard + - stale + - all + environment: + description: 'CloudKit environment' + required: false + default: 'development' + type: choice + options: + - development + - production + delay: + description: 'Rate limit delay in seconds' + required: false + default: '2.0' + force_rebuild: + description: 'Force rebuild binary (ignore cache)' + required: false + default: false + type: boolean + +env: + CLOUDKIT_CONTAINER_ID: ${{ secrets.CLOUDKIT_CONTAINER_ID || 'iCloud.com.brightdigit.Celestra' }} + CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} + CLOUDKIT_ENVIRONMENT: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && 'development' || github.event.inputs.environment || 'production' }} + CLOUDKIT_PRIVATE_KEY_PATH: /tmp/cloudkit_key.pem + +jobs: + # Determine which tier to run based on schedule or manual input + determine-tier: + runs-on: ubuntu-latest + outputs: + tier: ${{ steps.set-tier.outputs.tier }} + runs_high: ${{ steps.set-tier.outputs.runs_high }} + runs_standard: ${{ steps.set-tier.outputs.runs_standard }} + runs_stale: ${{ steps.set-tier.outputs.runs_stale }} + runs_pr_test: ${{ steps.set-tier.outputs.runs_pr_test }} + is_fork_pr: ${{ steps.set-tier.outputs.is_fork_pr }} + + steps: + - id: set-tier + run: | + # Check if pull request + if [ "${{ github.event_name }}" = "pull_request" ]; then + # Check if fork PR (secrets not available) + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "::notice::Fork PR detected - integration tests will be skipped (secrets not available)" + TIER="pr-test" + IS_FORK_PR="true" + else + echo "PR from repository branch - running integration tests" + TIER="pr-test" + IS_FORK_PR="false" + fi + # Check if push to branch + elif [ "${{ github.event_name }}" = "push" ]; then + echo "Push to branch ${{ github.ref_name }} - running integration tests" + TIER="pr-test" + IS_FORK_PR="false" + # Check if manual dispatch + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TIER="${{ github.event.inputs.tier }}" + echo "Manual dispatch: tier=$TIER" + IS_FORK_PR="false" + else + # Determine tier based on current time + HOUR=$(date -u +%H) + + # Check if this is 2 AM UTC (daily standard run) + if [ "$HOUR" -eq 2 ]; then + TIER="standard" + echo "Scheduled run (daily): tier=$TIER" + # Check if this is 3 AM UTC (weekly stale run on Sundays) + elif [ "$HOUR" -eq 3 ]; then + TIER="stale" + echo "Scheduled run (weekly): tier=$TIER" + # Default to high priority for any other scheduled times + else + TIER="high" + echo "Scheduled run (unexpected time): tier=$TIER" + fi + IS_FORK_PR="false" + fi + + echo "tier=$TIER" >> $GITHUB_OUTPUT + echo "is_fork_pr=$IS_FORK_PR" >> $GITHUB_OUTPUT + + # Set run flags for each tier + if [ "$TIER" = "all" ]; then + echo "runs_high=true" >> $GITHUB_OUTPUT + echo "runs_standard=true" >> $GITHUB_OUTPUT + echo "runs_stale=true" >> $GITHUB_OUTPUT + echo "runs_pr_test=false" >> $GITHUB_OUTPUT + elif [ "$TIER" = "pr-test" ]; then + echo "runs_high=false" >> $GITHUB_OUTPUT + echo "runs_standard=false" >> $GITHUB_OUTPUT + echo "runs_stale=false" >> $GITHUB_OUTPUT + # Only run PR test if not a fork PR + echo "runs_pr_test=$( [ "$IS_FORK_PR" = "false" ] && echo true || echo false )" >> $GITHUB_OUTPUT + else + echo "runs_high=$( [ "$TIER" = "high" ] && echo true || echo false )" >> $GITHUB_OUTPUT + echo "runs_standard=$( [ "$TIER" = "standard" ] && echo true || echo false )" >> $GITHUB_OUTPUT + echo "runs_stale=$( [ "$TIER" = "stale" ] && echo true || echo false )" >> $GITHUB_OUTPUT + echo "runs_pr_test=false" >> $GITHUB_OUTPUT + fi + + # Build the binary (cached based on code changes) + build: + runs-on: ubuntu-latest + container: swift:6.2-noble + outputs: + cache-hit: ${{ steps.cache-binary.outputs.cache-hit }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache compiled binary + id: cache-binary + uses: actions/cache@v4 + with: + path: .build/release/celestra-cloud + key: celestra-cloud-${{ runner.os }}-${{ hashFiles('Sources/**/*.swift', 'Package.swift') }}-${{ github.event.inputs.force_rebuild || 'false' }} + + - name: Update Package.swift to use remote MistKit branch + if: steps.cache-binary.outputs.cache-hit != 'true' + run: | + sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "main")|g' Package.swift + rm -f Package.resolved + + - name: Build CelestraCloud + if: steps.cache-binary.outputs.cache-hit != 'true' + run: swift build -c release --static-swift-stdlib + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: celestra-cloud-binary + path: .build/release/celestra-cloud + retention-days: 1 + + # High-priority feeds (hourly) + update-high-priority: + needs: [determine-tier, build] + if: needs.determine-tier.outputs.runs_high == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + + strategy: + matrix: + include: + - name: "Pass 1: Very popular feeds" + args: "--update-min-popularity 100 --update-max-failures 2 --update-delay 2.0 --update-limit 100" + - name: "Pass 2: Popular feeds" + args: "--update-min-popularity 10 --update-max-failures 5 --update-delay 2.5 --update-limit 100" + + fail-fast: false + + steps: + - name: Download binary artifact + uses: actions/download-artifact@v4 + with: + name: celestra-cloud-binary + path: ./bin + + - name: Make binary executable + run: chmod +x ./bin/celestra-cloud + + - name: Create CloudKit private key file + run: | + cat <<'EOF' > $CLOUDKIT_PRIVATE_KEY_PATH + ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + EOF + chmod 600 $CLOUDKIT_PRIVATE_KEY_PATH + + - name: ${{ matrix.name }} + run: | + echo "::group::${{ matrix.name }}" + echo "Running: ./bin/celestra-cloud update ${{ matrix.args }}" + ./bin/celestra-cloud update ${{ matrix.args }} --update-json-output-path ./feed-update-high-${{ strategy.job-index }}.json + echo "::endgroup::" + continue-on-error: true + + - name: Upload JSON report + if: always() + uses: actions/upload-artifact@v4 + with: + name: feed-update-high-${{ strategy.job-index }} + path: ./feed-update-high-${{ strategy.job-index }}.json + if-no-files-found: ignore + retention-days: 30 + + - name: Cleanup private key + if: always() + run: rm -f $CLOUDKIT_PRIVATE_KEY_PATH + + # Standard feeds (every 6 hours) + update-standard: + needs: [determine-tier, build] + if: needs.determine-tier.outputs.runs_standard == 'true' + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Download binary artifact + uses: actions/download-artifact@v4 + with: + name: celestra-cloud-binary + path: ./bin + + - name: Make binary executable + run: chmod +x ./bin/celestra-cloud + + - name: Create CloudKit private key file + run: | + cat <<'EOF' > $CLOUDKIT_PRIVATE_KEY_PATH + ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + EOF + chmod 600 $CLOUDKIT_PRIVATE_KEY_PATH + + - name: Update standard popularity feeds + run: | + echo "::group::Standard Feeds Update" + echo "Running feeds with minimum popularity of 10, max failures 5" + ./bin/celestra-cloud update \ + --update-min-popularity 10 \ + --update-max-failures 5 \ + --update-delay 2.5 \ + --update-limit 200 \ + --update-json-output-path ./feed-update-standard.json + echo "::endgroup::" + continue-on-error: true + + - name: Upload JSON report + if: always() + uses: actions/upload-artifact@v4 + with: + name: feed-update-standard + path: ./feed-update-standard.json + if-no-files-found: ignore + retention-days: 30 + + - name: Cleanup private key + if: always() + run: rm -f $CLOUDKIT_PRIVATE_KEY_PATH + + # Stale feeds (daily) + update-stale: + needs: [determine-tier, build] + if: needs.determine-tier.outputs.runs_stale == 'true' + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Download binary artifact + uses: actions/download-artifact@v4 + with: + name: celestra-cloud-binary + path: ./bin + + - name: Make binary executable + run: chmod +x ./bin/celestra-cloud + + - name: Create CloudKit private key file + run: | + cat <<'EOF' > $CLOUDKIT_PRIVATE_KEY_PATH + ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + EOF + chmod 600 $CLOUDKIT_PRIVATE_KEY_PATH + + - name: Update stale feeds + run: | + echo "::group::Stale Feeds Update" + # Calculate date 7 days ago in ISO8601 format (Linux date command) + WEEK_AGO=$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ') + echo "Updating feeds not attempted since: $WEEK_AGO" + ./bin/celestra-cloud update \ + --update-last-attempted-before "$WEEK_AGO" \ + --update-max-failures 10 \ + --update-delay 3.0 \ + --update-limit 300 \ + --update-json-output-path ./feed-update-stale.json + echo "::endgroup::" + continue-on-error: true + + - name: Upload JSON report + if: always() + uses: actions/upload-artifact@v4 + with: + name: feed-update-stale + path: ./feed-update-stale.json + if-no-files-found: ignore + retention-days: 30 + + - name: Cleanup private key + if: always() + run: rm -f $CLOUDKIT_PRIVATE_KEY_PATH + + # PR integration test (limited scope) + update-pr-test: + needs: [determine-tier, build] + if: needs.determine-tier.outputs.runs_pr_test == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Download binary artifact + uses: actions/download-artifact@v4 + with: + name: celestra-cloud-binary + path: ./bin + + - name: Make binary executable + run: chmod +x ./bin/celestra-cloud + + - name: Create CloudKit private key file + run: | + cat <<'EOF' > $CLOUDKIT_PRIVATE_KEY_PATH + ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + EOF + chmod 600 $CLOUDKIT_PRIVATE_KEY_PATH + + - name: Run PR integration test + run: | + echo "::group::PR Integration Test" + echo "Environment: $CLOUDKIT_ENVIRONMENT (development)" + echo "Testing with limited feeds (smoke test)" + echo "" + ./bin/celestra-cloud update \ + --update-limit 5 \ + --update-max-failures 0 \ + --update-delay 1.0 \ + --update-json-output-path ./feed-update-pr-test.json + echo "::endgroup::" + continue-on-error: false + + - name: Upload JSON report + if: always() + uses: actions/upload-artifact@v4 + with: + name: feed-update-pr-test + path: ./feed-update-pr-test.json + if-no-files-found: ignore + retention-days: 30 + + - name: Cleanup private key + if: always() + run: rm -f $CLOUDKIT_PRIVATE_KEY_PATH + + # Summary report + summary: + needs: [determine-tier, build, update-high-priority, update-standard, update-stale, update-pr-test] + if: always() + runs-on: ubuntu-latest + + steps: + - name: Download JSON reports + uses: actions/download-artifact@v4 + with: + pattern: feed-update-* + path: ./reports + merge-multiple: false + continue-on-error: true + + - name: Generate enhanced summary + run: | + echo "## RSS Feed Update Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tier:** ${{ needs.determine-tier.outputs.tier }}" >> $GITHUB_STEP_SUMMARY + echo "**Environment:** ${{ env.CLOUDKIT_ENVIRONMENT }}" >> $GITHUB_STEP_SUMMARY + echo "**Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY + echo "**Binary Cache:** ${{ needs.build.outputs.cache-hit == 'true' && '✅ Hit (reused)' || '🔨 Miss (rebuilt)' }}" >> $GITHUB_STEP_SUMMARY + + # Show fork PR notice if applicable + if [ "${{ needs.determine-tier.outputs.is_fork_pr }}" = "true" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ **Fork PR**: Integration tests skipped (secrets not available for security)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Job Results" >> $GITHUB_STEP_SUMMARY + echo "- Build: ${{ needs.build.result }}" >> $GITHUB_STEP_SUMMARY + echo "- High Priority: ${{ needs.update-high-priority.result || 'skipped' }}" >> $GITHUB_STEP_SUMMARY + echo "- Standard: ${{ needs.update-standard.result || 'skipped' }}" >> $GITHUB_STEP_SUMMARY + echo "- Stale: ${{ needs.update-stale.result || 'skipped' }}" >> $GITHUB_STEP_SUMMARY + echo "- PR Test: ${{ needs.update-pr-test.result || 'skipped' }}" >> $GITHUB_STEP_SUMMARY + + # Parse JSON reports if available + if [ -d "./reports" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Update Statistics" >> $GITHUB_STEP_SUMMARY + + # Aggregate stats from all JSON files + total_feeds=0 + success_count=0 + error_count=0 + skipped_count=0 + not_modified_count=0 + articles_created=0 + articles_updated=0 + + for report_dir in ./reports/feed-update-*; do + if [ -d "$report_dir" ]; then + for json_file in "$report_dir"/*.json; do + if [ -f "$json_file" ]; then + total_feeds=$((total_feeds + $(jq -r '.summary.totalFeeds // 0' "$json_file"))) + success_count=$((success_count + $(jq -r '.summary.successCount // 0' "$json_file"))) + error_count=$((error_count + $(jq -r '.summary.errorCount // 0' "$json_file"))) + skipped_count=$((skipped_count + $(jq -r '.summary.skippedCount // 0' "$json_file"))) + not_modified_count=$((not_modified_count + $(jq -r '.summary.notModifiedCount // 0' "$json_file"))) + articles_created=$((articles_created + $(jq -r '.summary.articlesCreated // 0' "$json_file"))) + articles_updated=$((articles_updated + $(jq -r '.summary.articlesUpdated // 0' "$json_file"))) + fi + done + fi + done + + echo "- **Total Feeds Processed:** $total_feeds" >> $GITHUB_STEP_SUMMARY + echo "- **Successful:** ✅ $success_count" >> $GITHUB_STEP_SUMMARY + echo "- **Errors:** ❌ $error_count" >> $GITHUB_STEP_SUMMARY + echo "- **Skipped (robots.txt):** ⏭️ $skipped_count" >> $GITHUB_STEP_SUMMARY + echo "- **Not Modified (304):** ℹ️ $not_modified_count" >> $GITHUB_STEP_SUMMARY + echo "- **Articles Created:** 📝 $articles_created" >> $GITHUB_STEP_SUMMARY + echo "- **Articles Updated:** 📝 $articles_updated" >> $GITHUB_STEP_SUMMARY + + # Calculate success rate if we have data + if [ $total_feeds -gt 0 ]; then + success_rate=$((success_count * 100 / total_feeds)) + echo "- **Success Rate:** ${success_rate}%" >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Smart Scheduling Strategy" >> $GITHUB_STEP_SUMMARY + echo "- **High Priority (Hourly)**: Popular feeds (min 10-100 subscribers), max 5 failures" >> $GITHUB_STEP_SUMMARY + echo "- **Standard (Every 6h)**: Medium popularity feeds (min 10 subscribers), max 5 failures" >> $GITHUB_STEP_SUMMARY + echo "- **Stale (Daily)**: Feeds not updated in 7+ days, max 10 failures" >> $GITHUB_STEP_SUMMARY + echo "- **PR Test**: Limited to 5 feeds, max 0 failures (smoke test)" >> $GITHUB_STEP_SUMMARY + + - name: Generate detailed markdown report + if: success() || failure() + run: | + REPORT_FILE="feed-update-detailed-report.md" + + cat > "$REPORT_FILE" <<'REPORT_HEADER' + # RSS Feed Update - Detailed Report + + ## Overview + REPORT_HEADER + + echo "- **Tier:** ${{ needs.determine-tier.outputs.tier }}" >> "$REPORT_FILE" + echo "- **Environment:** ${{ env.CLOUDKIT_ENVIRONMENT }}" >> "$REPORT_FILE" + echo "- **Trigger:** ${{ github.event_name }}" >> "$REPORT_FILE" + echo "- **Workflow Run:** https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> "$REPORT_FILE" + echo "" >> "$REPORT_FILE" + + # Add aggregate statistics if JSON reports are available + if [ -d "./reports" ]; then + echo "## Summary Statistics" >> "$REPORT_FILE" + echo "" >> "$REPORT_FILE" + + # Aggregate stats (same as above) + total_feeds=0 + success_count=0 + error_count=0 + skipped_count=0 + not_modified_count=0 + articles_created=0 + articles_updated=0 + + for report_dir in ./reports/feed-update-*; do + if [ -d "$report_dir" ]; then + for json_file in "$report_dir"/*.json; do + if [ -f "$json_file" ]; then + total_feeds=$((total_feeds + $(jq -r '.summary.totalFeeds // 0' "$json_file"))) + success_count=$((success_count + $(jq -r '.summary.successCount // 0' "$json_file"))) + error_count=$((error_count + $(jq -r '.summary.errorCount // 0' "$json_file"))) + skipped_count=$((skipped_count + $(jq -r '.summary.skippedCount // 0' "$json_file"))) + not_modified_count=$((not_modified_count + $(jq -r '.summary.notModifiedCount // 0' "$json_file"))) + articles_created=$((articles_created + $(jq -r '.summary.articlesCreated // 0' "$json_file"))) + articles_updated=$((articles_updated + $(jq -r '.summary.articlesUpdated // 0' "$json_file"))) + fi + done + fi + done + + echo "| Metric | Count |" >> "$REPORT_FILE" + echo "|--------|-------|" >> "$REPORT_FILE" + echo "| Total Feeds | $total_feeds |" >> "$REPORT_FILE" + echo "| Successful | $success_count |" >> "$REPORT_FILE" + echo "| Errors | $error_count |" >> "$REPORT_FILE" + echo "| Skipped (robots.txt) | $skipped_count |" >> "$REPORT_FILE" + echo "| Not Modified (304) | $not_modified_count |" >> "$REPORT_FILE" + echo "| Articles Created | $articles_created |" >> "$REPORT_FILE" + echo "| Articles Updated | $articles_updated |" >> "$REPORT_FILE" + + if [ $total_feeds -gt 0 ]; then + success_rate=$((success_count * 100 / total_feeds)) + echo "| Success Rate | ${success_rate}% |" >> "$REPORT_FILE" + fi + + echo "" >> "$REPORT_FILE" + + # Add per-tier breakdown with per-feed details + echo "## Detailed Feed Results" >> "$REPORT_FILE" + echo "" >> "$REPORT_FILE" + + for report_dir in ./reports/feed-update-*; do + if [ -d "$report_dir" ]; then + tier_name=$(basename "$report_dir" | sed 's/feed-update-//') + echo "### Tier: $tier_name" >> "$REPORT_FILE" + echo "" >> "$REPORT_FILE" + + for json_file in "$report_dir"/*.json; do + if [ -f "$json_file" ]; then + # Extract tier summary + tier_total=$(jq -r '.summary.totalFeeds // 0' "$json_file") + tier_success=$(jq -r '.summary.successCount // 0' "$json_file") + tier_errors=$(jq -r '.summary.errorCount // 0' "$json_file") + + echo "**Summary:** $tier_total feeds processed ($tier_success successful, $tier_errors errors)" >> "$REPORT_FILE" + echo "" >> "$REPORT_FILE" + + # List all feeds with their status + echo "| Feed URL | Status | Articles Created | Articles Updated | Duration (s) | Error |" >> "$REPORT_FILE" + echo "|----------|--------|------------------|------------------|--------------|-------|" >> "$REPORT_FILE" + + jq -r '.feeds[] | [.feedURL, .status, .articlesCreated, .articlesUpdated, (.duration | tostring), (.error // "N/A")] | @tsv' "$json_file" | \ + while IFS=$'\t' read -r url status created updated duration error; do + # Truncate URL for readability + short_url=$(echo "$url" | sed 's|https\?://||' | cut -c1-50) + echo "| $short_url | $status | $created | $updated | $(printf "%.2f" $duration) | $error |" >> "$REPORT_FILE" + done + + echo "" >> "$REPORT_FILE" + fi + done + fi + done + else + echo "## No JSON Reports Available" >> "$REPORT_FILE" + echo "" >> "$REPORT_FILE" + echo "JSON reports were not generated or could not be downloaded." >> "$REPORT_FILE" + fi + + echo "Report generated: $REPORT_FILE" + + - name: Upload detailed markdown report + if: success() || failure() + uses: actions/upload-artifact@v4 + with: + name: feed-update-detailed-report + path: feed-update-detailed-report.md + if-no-files-found: ignore + retention-days: 90 diff --git a/Examples/CelestraCloud/.gitignore b/Examples/CelestraCloud/.gitignore new file mode 100644 index 00000000..46a5ec69 --- /dev/null +++ b/Examples/CelestraCloud/.gitignore @@ -0,0 +1,193 @@ +# macOS +.DS_Store + +# Swift Package Manager +.build/ +.swiftpm/ +DerivedData/ +.index-build/ + +# Xcode +*.xcodeproj +*.xcworkspace +xcuserdata/ + +# IDE +.vscode/ +.idea/ + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +# End of https://www.toptal.com/developers/gitignore/api/node + +dev-debug.log +# Environment variables +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# OS specific + +.mint/ +/Keys/ +.claude/settings.local.json + +# Prevent accidental commits of private keys/certificates (server-to-server auth) +*.p8 +*.pem +*.key +*.cer +*.crt +*.der +*.p12 +*.pfx + +# Allow placeholder docs/samples in Keys +!Keys/README.md +!Keys/*.example.* + +# Task files +# tasks.json +# tasks/ diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo new file mode 100644 index 00000000..ed99fc05 --- /dev/null +++ b/Examples/CelestraCloud/.gitrepo @@ -0,0 +1,12 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +[subrepo] + remote = git@github.com:brightdigit/CelestraCloud.git + branch = mistkit + commit = 319bc6208763c31706b9e10c3608d9f7cc5c2ef5 + parent = 72b6cb0efe09735817079569eb702104c18f905c + method = merge + cmdver = 0.4.9 diff --git a/Examples/CelestraCloud/.periphery.yml b/Examples/CelestraCloud/.periphery.yml new file mode 100644 index 00000000..85b884af --- /dev/null +++ b/Examples/CelestraCloud/.periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/Examples/CelestraCloud/.swift-format b/Examples/CelestraCloud/.swift-format new file mode 100644 index 00000000..d5fd1870 --- /dev/null +++ b/Examples/CelestraCloud/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : true, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} \ No newline at end of file diff --git a/Examples/CelestraCloud/.swiftlint.yml b/Examples/CelestraCloud/.swiftlint.yml new file mode 100644 index 00000000..2698b9df --- /dev/null +++ b/Examples/CelestraCloud/.swiftlint.yml @@ -0,0 +1,140 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - Mint + - Examples + - Sources/MistKit/Generated +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace + - optional_data_string_conversion + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error \ No newline at end of file diff --git a/Examples/CelestraCloud/CHANGELOG.md b/Examples/CelestraCloud/CHANGELOG.md new file mode 100644 index 00000000..484bbf72 --- /dev/null +++ b/Examples/CelestraCloud/CHANGELOG.md @@ -0,0 +1,97 @@ +# Changelog + +All notable changes to CelestraCloud 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). + +## [1.0.0] - 2025-12-11 + +### Added +- Initial production release of CelestraCloud +- RSS feed management with CloudKit public database sync +- MistKit integration for CloudKit Web Services operations +- Query filtering and sorting demonstrations using MistKit's QueryFilter API +- Batch operations for efficient article uploads with chunking (10 records per batch) +- Duplicate detection via GUID-based queries with content hash comparison +- Comprehensive web etiquette features (using services from CelestraKit): + - Per-domain rate limiting (RateLimiter actor from CelestraKit) + - Robots.txt compliance checking (RobotsTxtService from CelestraKit) + - Conditional HTTP requests (If-Modified-Since, ETag support) + - Failure tracking and exponential backoff + - Respect for feed TTL and update intervals +- Server-to-Server authentication for CloudKit access +- CelestraKit dependency for shared models (Feed, Article) and web etiquette services (RateLimiter, RobotsTxtService) +- Comprehensive test suite with 22 local tests across 3 suites (additional 19 tests in CelestraKit) +- CI/CD pipeline with GitHub Actions (Ubuntu + macOS builds, linting) +- Linting and code formatting (SwiftLint, SwiftFormat) +- Makefile for common development tasks +- MIT License + +### Features +- **Commands**: + - `add-feed` - Parse and validate RSS feeds, store in CloudKit + - `update` - Fetch and update feeds with filtering options + - `clear` - Delete all records from CloudKit +- **CloudKit Field Mapping**: + - Direct field mapping pattern (Feed+MistKit, Article+MistKit extensions) + - Boolean storage as INT64 (0/1) for CloudKit compatibility + - Automatic field type conversion with FieldValue enum +- **Local Services**: + - CloudKitService extensions for CelestraCloud operations + - RSSFetcherService wrapping SyndiKit for RSS parsing + - CelestraLogger with structured logging categories + - CelestraError for error handling +- **Services from CelestraKit**: + - RobotsTxtService actor for respectful web crawling + - RateLimiter actor for thread-safe delay management + - Feed and Article models for shared data structures + +### Infrastructure +- Package renamed from "Celestra" to "CelestraCloud" +- Executable renamed from "celestra" to "celestra-cloud" +- CelestraKit external dependency added (branch v0.0.1) for shared models and services: + - Enables code reuse across Celestra ecosystem (CLI, future mobile apps, etc.) + - Provides Feed and Article models + - Provides RateLimiter and RobotsTxtService for web etiquette +- SyndiKit dependency migrated from local path to GitHub (v0.6.1) +- Migrated comprehensive development infrastructure from MistKit: + - GitHub Actions workflows (build, test, lint) + - SwiftLint configuration with 90+ rules + - SwiftFormat configuration + - Mintfile for tool management (SwiftFormat, SwiftLint, Periphery) + - Xcodegen project configuration + - Development scripts (lint.sh, header.sh, setup-cloudkit-schema.sh) +- Documentation reorganized: + - User-facing: README.md, LICENSE, CHANGELOG.md + - AI context: CLAUDE.md + - Development context: .claude/ directory (PRD, implementation notes, schema workflow) + +### Technical Details +- **Platform**: macOS 26+ (Swift 6.2) +- **Concurrency**: Full Swift 6 concurrency support with strict checking +- **Dependencies**: MistKit 1.0.0-alpha.3, SyndiKit 0.6.1, ArgumentParser, swift-log +- **CloudKit**: Public database with Feed and Article record types +- **Schema**: Text-based .ckdb schema with cktool deployment + +### Documentation +- Comprehensive CLAUDE.md for AI agent guidance +- README with setup instructions, usage examples, and feature overview +- Example .env file for CloudKit configuration +- CloudKit schema deployment automation + +## [Unreleased] + +### Planned for Future Releases +- iOS/macOS GUI client +- Private database support +- Multi-user authentication +- Feed recommendation system +- Advanced search beyond CloudKit queries +- Automatic feed discovery +- DocC published documentation +- Code coverage reporting integration + +--- + +**Note**: This is the first production release. CelestraCloud demonstrates MistKit's CloudKit integration capabilities through a fully functional command-line RSS reader with comprehensive web etiquette best practices. diff --git a/Examples/CelestraCloud/CLAUDE.md b/Examples/CelestraCloud/CLAUDE.md new file mode 100644 index 00000000..a445b212 --- /dev/null +++ b/Examples/CelestraCloud/CLAUDE.md @@ -0,0 +1,447 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Celestra is a command-line RSS reader that demonstrates MistKit's CloudKit integration capabilities. It fetches RSS feeds, stores them in CloudKit's public database, and implements comprehensive web etiquette best practices including rate limiting, robots.txt checking, and conditional HTTP requests. + +**Tech Stack**: Swift 6.2, MistKit (CloudKit wrapper), CelestraKit (shared models & services), SyndiKit (RSS parsing), Swift Configuration (configuration management) + +## Common Commands + +### Build and Run + +```bash +# Build the project +swift build + +# Run with environment variables +source .env +swift run celestra-cloud + +# Add a feed +swift run celestra-cloud add-feed https://example.com/feed.xml + +# Update feeds with filters +swift run celestra-cloud update +swift run celestra-cloud update --update-last-attempted-before 2025-01-01T00:00:00Z +swift run celestra-cloud update --update-min-popularity 10 --update-delay 3.0 +swift run celestra-cloud update --update-limit 5 --update-max-failures 0 + +# Clear all data +swift run celestra-cloud clear --confirm + +# Using both environment variables and CLI arguments (CLI wins) +UPDATE_DELAY=2.0 swift run celestra-cloud update --update-delay 3.0 +``` + +### Environment Setup + +Required environment variables (see `.env.example`): +- `CLOUDKIT_KEY_ID` - Server-to-Server key ID from Apple Developer Console +- `CLOUDKIT_PRIVATE_KEY_PATH` - Path to `.pem` private key file + +Optional environment variables: +- `CLOUDKIT_CONTAINER_ID` - CloudKit container identifier (default: `iCloud.com.brightdigit.Celestra`) +- `CLOUDKIT_ENVIRONMENT` - Either `development` or `production` (default: `development`) + +### CloudKit Schema Management + +```bash +# Automated schema deployment (requires cktool) +export CLOUDKIT_CONTAINER_ID="iCloud.com.brightdigit.Celestra" +export CLOUDKIT_TEAM_ID="YOUR_TEAM_ID" +export CLOUDKIT_ENVIRONMENT="development" +./Scripts/setup-cloudkit-schema.sh +``` + +Schema is defined in `schema.ckdb` using CloudKit's text-based schema language. + +## Architecture + +### High-Level Structure + +``` +Sources/CelestraCloud/ +├── Celestra.swift # CLI entry point +└── Commands/ # CLI subcommands + ├── AddFeedCommand.swift # Parse and add RSS feeds + ├── UpdateCommand.swift # Fetch/update feeds (shows MistKit QueryFilter) + └── ClearCommand.swift # Delete all records + +Sources/CelestraCloudKit/ +├── Configuration/ # Swift Configuration integration +│ ├── CelestraConfiguration.swift # Root config struct +│ ├── CloudKitConfiguration.swift # CloudKit credentials config +│ ├── UpdateCommandConfiguration.swift # Update command options +│ ├── ConfigurationLoader.swift # Multi-source config loader +│ └── ConfigurationError.swift # Enhanced errors +├── CelestraConfig.swift # CloudKit service factory +├── Services/ +│ ├── CloudKitService+Celestra.swift # MistKit operations +│ ├── CelestraError.swift # Error types +│ └── CelestraLogger.swift # Structured logging +├── Models/ +│ └── BatchOperationResult.swift # Batch operation tracking +└── Extensions/ + ├── Feed+MistKit.swift # Feed ↔ CloudKit conversion + └── Article+MistKit.swift # Article ↔ CloudKit conversion +``` + +**External Dependencies**: The `Feed` and `Article` models, along with `RateLimiter` and `RobotsTxtService`, are provided by the CelestraKit package for reuse across CLI and other clients. + +### Key Architectural Patterns + +**1. MistKit Integration** + +CloudKitService is configured in `CelestraConfig.createCloudKitService()`: +- Server-to-Server authentication using PEM keys +- Public database access for shared feeds +- Environment-based configuration (dev/prod) + +All CloudKit operations are in `CloudKitService+Celestra.swift` extension: +- `queryFeeds()` - Demonstrates QueryFilter and QuerySort APIs +- `createArticles()` / `updateArticles()` - Batch operations with chunking +- `queryArticlesByGUIDs()` - Duplicate detection queries + +**2. Field Mapping Pattern** + +Models use direct field mapping with validation (CloudKitConvertible protocol): + +```swift +// To CloudKit +func toFieldsDict() -> [String: FieldValue] { + var fields: [String: FieldValue] = [ + "title": .string(title), + "isActive": .int64(isActive ? 1 : 0) // Booleans as INT64 + ] + // Optional fields only added if present + if let description = description { + fields["description"] = .string(description) + } + return fields +} + +// From CloudKit - with validation (throws CloudKitConversionError) +init(from record: RecordInfo) throws { + // Required fields throw if missing or empty + guard case .string(let title) = record.fields["title"], + !title.isEmpty else { + throw CloudKitConversionError.missingRequiredField( + fieldName: "title", + recordType: "Feed" + ) + } + + // Boolean extraction with default + if case .int64(let value) = record.fields["isActive"] { + self.isActive = value != 0 + } else { + self.isActive = true // Default for optional fields + } +} +``` + +**Validation Behavior:** +- Required fields (feedURL, title for Feed; feedRecordName, guid, title, url for Article) throw `CloudKitConversionError` if missing or empty +- Invalid articles are skipped with warning logs; one bad article won't fail the entire feed update +- Feed conversion errors propagate (fail-fast for feed metadata) + +**3. Duplicate Detection Strategy** + +UpdateCommand implements GUID-based duplicate detection: +1. Extract GUIDs from fetched articles +2. Query CloudKit for existing articles with those GUIDs (`queryArticlesByGUIDs`) +3. Separate into new vs modified articles (using `contentHash` comparison) +4. Create new articles, update modified ones, skip unchanged + +This minimizes CloudKit writes and prevents duplicate content. + +**4. Batch Operations** + +Articles are processed in batches of 10 (conservative to keep payload size manageable with full content): +- Non-atomic operations allow partial success +- Each batch tracked in `BatchOperationResult` +- Provides success rate, failure count, and detailed error tracking +- See `createArticles()` / `updateArticles()` in CloudKitService+Celestra.swift + +**5. Web Etiquette Implementation** + +Celestra is a respectful RSS client: +- **Rate Limiting**: Uses `RateLimiter` actor from CelestraKit - configurable delays between feeds (default 2s), per-domain tracking +- **Robots.txt**: Uses `RobotsTxtService` actor from CelestraKit - parses and respects robots.txt rules +- **Conditional Requests**: Uses If-Modified-Since/ETag headers, handles 304 Not Modified +- **Failure Tracking**: Tracks consecutive failures per feed, can filter by max failures +- **Update Intervals**: Respects feed's `minUpdateInterval` to avoid over-fetching +- **User-Agent**: Identifies as "Celestra/1.0 (MistKit RSS Reader; +https://github.com/brightdigit/MistKit)" + +All web etiquette features are demonstrated in UpdateCommand.swift using services from CelestraKit. + +## CloudKit Schema + +Two record types in public database: + +**Feed**: RSS feed metadata +- Key fields: `feedURL` (QUERYABLE SORTABLE), `title` (SEARCHABLE) +- Metrics: `totalAttempts`, `successfulAttempts`, `subscriberCount` +- Web etiquette: `etag`, `lastModified`, `failureCount`, `minUpdateInterval` +- Booleans stored as INT64: `isActive`, `isFeatured`, `isVerified` + +**Article**: RSS article content +- Key fields: `guid` (QUERYABLE SORTABLE), `feedRecordName` (STRING) +- Content: `title`, `excerpt`, `content`, `contentText` (all SEARCHABLE) +- Deduplication: `contentHash` (SHA256), `guid` +- TTL: `expiresAt` (QUERYABLE SORTABLE) for cleanup + +**Relationship Design**: Uses string-based `feedRecordName` instead of CKReference for simplicity and clearer querying patterns. Trade-off: Manual cascade delete vs automatic with CKReference. + +## Swift Configuration (v1.0.0) + +CelestraCloud uses Apple's Swift Configuration library for unified configuration management across environment variables and command-line arguments. + +### Configuration Architecture + +**Priority Order**: CLI arguments > Environment variables > Defaults + +```swift +// ConfigurationLoader uses CommandLineArgumentsProvider +let loader = ConfigurationLoader() +let config = await loader.loadConfiguration() +``` + +### Built-in Providers Used + +1. **CommandLineArgumentsProvider** - Automatic CLI argument parsing (highest priority) +2. **EnvironmentVariablesProvider** - System environment variables + +**Package Trait**: `CommandLineArguments` trait is enabled in Package.swift to support CommandLineArgumentsProvider. + +### Configuration Reference + +#### CloudKit Configuration + +CloudKit authentication credentials must be provided via environment variables: + +| Environment Variable | Type | Default | Required | Description | +|---------------------|------|---------|----------|-------------| +| `CLOUDKIT_CONTAINER_ID` | String | `iCloud.com.brightdigit.Celestra` | No | CloudKit container identifier | +| `CLOUDKIT_KEY_ID` | String | None | **Yes** | Server-to-Server key ID from Apple Developer Console | +| `CLOUDKIT_PRIVATE_KEY_PATH` | String | None | **Yes** | Absolute path to `.pem` private key file | +| `CLOUDKIT_ENVIRONMENT` | String | `development` | No | CloudKit environment: `development` or `production` | + +**Note**: CloudKit credentials (`CLOUDKIT_KEY_ID` and `CLOUDKIT_PRIVATE_KEY_PATH`) are marked as secrets and automatically redacted from logs. + +#### Update Command Configuration (Optional) + +All update command settings are **optional** and can be provided via environment variables OR CLI arguments: + +| Option | Env Variable | CLI Argument | Type | Default | Description | +|--------|--------------|--------------|------|---------|-------------| +| Delay | `UPDATE_DELAY` | `--update-delay ` | Double | `2.0` | Delay between feed updates in seconds | +| Skip Robots | `UPDATE_SKIP_ROBOTS_CHECK` | `--update-skip-robots-check` | Bool | `false` | Skip robots.txt validation (flag) | +| Max Failures | `UPDATE_MAX_FAILURES` | `--update-max-failures ` | Int | None | Skip feeds above this failure threshold | +| Min Popularity | `UPDATE_MIN_POPULARITY` | `--update-min-popularity ` | Int | None | Only update feeds with minimum subscribers | +| Last Attempted Before | `UPDATE_LAST_ATTEMPTED_BEFORE` | `--update-last-attempted-before ` | Date | None | Only update feeds attempted before this date | +| Limit | `UPDATE_LIMIT` | `--update-limit ` | Int | None | Maximum number of feeds to query and update | +| JSON Output Path | `UPDATE_JSON_OUTPUT_PATH` | `--update-json-output-path ` | String | None | Path to write JSON report with detailed results | + +**Date Format**: ISO8601 (e.g., `2025-01-01T00:00:00Z`) + +### Configuration Key Mapping + +**Command-line arguments** use kebab-case: +- `--cloudkit-container-id` → `cloudkit.container_id` +- `--update-delay` → `update.delay` +- `--update-skip-robots-check` → `update.skip_robots_check` + +**Environment variables** use SCREAMING_SNAKE_CASE: +- `CLOUDKIT_CONTAINER_ID` → `cloudkit.container_id` +- `UPDATE_DELAY` → `update.delay` +- `UPDATE_SKIP_ROBOTS_CHECK` → `update.skip_robots_check` + +### Usage Examples + +**Note**: Examples below assume `celestra-cloud` is in your PATH. If running from source, prefix commands with `swift run` (e.g., `swift run celestra-cloud update`). + +**Via environment variables:** +```bash +export CLOUDKIT_CONTAINER_ID="iCloud.com.brightdigit.Celestra" +export UPDATE_DELAY=3.0 +export UPDATE_MAX_FAILURES=5 +celestra-cloud update +``` + +**Via command-line arguments:** +```bash +celestra-cloud update \ + --update-delay 3.0 \ + --update-max-failures 5 \ + --update-min-popularity 10 +``` + +**Mixed (CLI overrides ENV):** +```bash +# Environment has UPDATE_DELAY=2.0, but CLI overrides to 5.0 +UPDATE_DELAY=2.0 celestra-cloud update --update-delay 5.0 +# Uses 5.0 (CLI wins) +``` + +**Filtering by date:** +```bash +# Only update feeds last attempted before January 1, 2025 +celestra-cloud update --update-last-attempted-before 2025-01-01T00:00:00Z +``` + +**With JSON output for detailed reporting:** +```bash +# Generate JSON report with per-feed results and summary statistics +celestra-cloud update --update-json-output-path /tmp/feed-update-report.json + +# Combine with other options for CI/CD workflows +celestra-cloud update \ + --update-limit 10 \ + --update-delay 1.0 \ + --update-json-output-path ./build/feed-update-results.json +``` + +### Adding New Configuration Options + +To add a new configuration option (e.g., `--concurrency`): + +1. **Add to configuration struct:** +```swift +// In UpdateCommandConfiguration.swift +public var concurrency: Int = 1 +``` + +2. **Update ConfigurationLoader:** +```swift +// In ConfigurationLoader.loadConfiguration() +let update = UpdateCommandConfiguration( + // ... existing fields + concurrency: readInt(forKey: "update.concurrency") ?? 1 +) +``` + +3. **Access in command:** +```swift +// In UpdateCommand.swift +let config = try await loader.loadConfiguration() +let concurrency = config.update.concurrency +``` + +**No manual parsing needed!** Users can now use: +- `--update-concurrency 3` (CLI - kebab-case) +- `UPDATE_CONCURRENCY=3` (environment - SCREAMING_SNAKE_CASE) + +### Key Documentation + +- See `.claude/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md` for complete Swift Configuration API reference +- Provider hierarchy documentation: Configuration providers are queried in order, first non-nil value wins + +## Swift 6.2 Features + +Package.swift enables extensive Swift 6.2 upcoming and experimental features: +- Strict concurrency checking (`-strict-concurrency=complete`) +- Existential `any` keyword +- Typed throws +- Noncopyable generics +- Move-only types +- Variadic generics + +Code must be concurrency-safe with proper actor isolation. + +## Development Guidelines + +**When Adding Features:** +- MistKit operations go in `CloudKitService+Celestra.swift` extension +- Configuration options: Add to appropriate struct in `Configuration/` directory, update `ConfigurationLoader` +- All CloudKit field types: Use FieldValue enum (.string, .int64, .date, .double, etc.) +- Booleans: Always store as INT64 (0/1) in CloudKit schema +- Batch operations: Chunk into batches of 10 for large payloads, use non-atomic for partial success +- Logging: Use CelestraLogger categories (cloudkit, rss, operations, errors) + +**External Dependencies:** +- `RateLimiter`, `RobotsTxtService`, and `RSSFetcherService` are from CelestraKit - contributions should be made to that repository +- Feed and Article models are also in CelestraKit for reuse across the Celestra ecosystem +- Web etiquette suite (rate limiting, robots.txt, RSS fetching) is now complete in CelestraKit + +**Testing CloudKit Operations:** +- Use development environment first +- Schema changes require redeployment via `./Scripts/setup-cloudkit-schema.sh` +- Clear data with `celestra clear --confirm` between tests + +**Key Documentation:** +- `.claude/IMPLEMENTATION_NOTES.md` - Design decisions, patterns, and technical context +- `.claude/AI_SCHEMA_WORKFLOW.md` - CloudKit schema design guide for AI agents +- `.claude/CLOUDKIT_SCHEMA_SETUP.md` - Schema deployment instructions + +## Pull Request Testing + +Integration tests automatically validate the update-feeds workflow on all pull requests to `main`: + +**Test Scope:** +- Runs against CloudKit **development environment** only (production never touched) +- Limited smoke test: Maximum 5 feeds, zero failures allowed +- Completes in ~2-5 minutes (vs. production's 60-120 minute runs) +- Uses same binary caching as production workflow + +**Behavior:** +- **Repository branch PRs**: Full integration test runs automatically +- **Fork PRs**: Tests skipped gracefully (GitHub security prevents secret access) +- Fails fast on errors (unlike production which continues on error) + +**Workflow Details:** +- Workflow: `.github/workflows/update-feeds.yml` (shared with scheduled production runs) +- Tier: `pr-test` (alongside `high`, `standard`, `stale` tiers) +- Filter: `--update-limit 5 --update-max-failures 0 --update-delay 1.0` +- Timeout: 10 minutes maximum + +**External Contributors:** +Fork PRs cannot run integration tests due to GitHub's security model (secrets unavailable). Maintainers can create repository branches for contributors to run tests before merge, or tests will validate after merge. + +## Important Patterns + +**QueryFilter Examples** (see CloudKitService+Celestra.swift:44-68): +```swift +var filters: [QueryFilter] = [] +filters.append(.lessThan("lastAttempted", .date(cutoffDate))) +filters.append(.greaterThanOrEquals("subscriberCount", .int64(minPopularity))) + +let records = try await queryRecords( + recordType: "Feed", + filters: filters.isEmpty ? nil : filters, + sortBy: [.ascending("feedURL")], + limit: limit +) +``` + +**Duplicate Detection** (see UpdateCommand.swift:192-236): +```swift +let guids = articles.map { $0.guid } +let existingArticles = try await service.queryArticlesByGUIDs(guids, feedRecordName: recordName) +let existingMap = Dictionary(uniqueKeysWithValues: existingArticles.map { ($0.guid, $0) }) + +for article in articles { + if let existing = existingMap[article.guid] { + if existing.contentHash != article.contentHash { + modifiedArticles.append(article.withRecordName(existing.recordName)) + } + } else { + newArticles.append(article) + } +} +``` + +**Server-to-Server Auth** (see CelestraConfig.swift): +```swift +let privateKeyPEM = try String(contentsOfFile: privateKeyPath, encoding: .utf8) +let tokenManager = try ServerToServerAuthManager(keyID: keyID, pemString: privateKeyPEM) +let service = try CloudKitService( + containerIdentifier: containerID, + tokenManager: tokenManager, + environment: environment, + database: .public +) +``` diff --git a/Examples/CelestraCloud/LICENSE b/Examples/CelestraCloud/LICENSE new file mode 100644 index 00000000..639fbd5f --- /dev/null +++ b/Examples/CelestraCloud/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 BrightDigit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Examples/CelestraCloud/Makefile b/Examples/CelestraCloud/Makefile new file mode 100644 index 00000000..72b08b43 --- /dev/null +++ b/Examples/CelestraCloud/Makefile @@ -0,0 +1,55 @@ +.PHONY: help build test lint format run setup-cloudkit clean install + +# Default target +help: + @echo "Available targets:" + @echo " install - Install development dependencies via Mint" + @echo " build - Build the project" + @echo " test - Run unit tests" + @echo " lint - Run linters (SwiftLint, SwiftFormat)" + @echo " format - Auto-format code (SwiftFormat)" + @echo " run - Run CLI (requires .env sourced)" + @echo " setup-cloudkit - Deploy CloudKit schema" + @echo " clean - Clean build artifacts" + @echo " help - Show this help message" + +# Install dev dependencies +install: + @echo "📦 Installing development tools via Mint..." + @mint bootstrap + +# Build the project +build: + @echo "🔨 Building CelestraCloud..." + @swift build + +# Run unit tests +test: + @echo "🧪 Running tests..." + @swift test + +# Run linters +lint: + @echo "🔍 Running linters..." + @./Scripts/lint.sh + +# Auto-format code +format: + @echo "✨ Formatting code..." + @FORMAT_ONLY=1 ./Scripts/lint.sh + +# Run CLI (assumes environment sourced) +run: + @echo "🚀 Running celestra-cloud..." + @swift run celestra-cloud + +# Deploy CloudKit schema +setup-cloudkit: + @echo "☁️ Deploying CloudKit schema..." + @./Scripts/setup-cloudkit-schema.sh + +# Clean build artifacts +clean: + @echo "🧹 Cleaning build artifacts..." + @swift package clean + @rm -rf .build diff --git a/Examples/CelestraCloud/Mintfile b/Examples/CelestraCloud/Mintfile new file mode 100644 index 00000000..3586a2be --- /dev/null +++ b/Examples/CelestraCloud/Mintfile @@ -0,0 +1,4 @@ +swiftlang/swift-format@602.0.0 +realm/SwiftLint@0.62.2 +peripheryapp/periphery@3.2.0 +apple/swift-openapi-generator@1.10.3 diff --git a/Examples/Bushel/Package.resolved b/Examples/CelestraCloud/Package.resolved similarity index 54% rename from Examples/Bushel/Package.resolved rename to Examples/CelestraCloud/Package.resolved index c26c2271..d3e8e3fd 100644 --- a/Examples/Bushel/Package.resolved +++ b/Examples/CelestraCloud/Package.resolved @@ -1,40 +1,13 @@ { - "originHash" : "49101adc127b15b12356a82f5e23d4330446434fff2c05a660d994bbea54b871", + "originHash" : "99359579bf8e74b5ee7b13a4936b0d9e1d09aa0ff2eb5bb043a63f8c00d1fea5", "pins" : [ { - "identity" : "ipswdownloads", + "identity" : "celestrakit", "kind" : "remoteSourceControl", - "location" : "https://github.com/brightdigit/IPSWDownloads.git", + "location" : "https://github.com/brightdigit/CelestraKit.git", "state" : { - "revision" : "2e8ad36b5f74285dbe104e7ae99f8be0cd06b7b8", - "version" : "1.0.2" - } - }, - { - "identity" : "lrucache", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nicklockwood/LRUCache.git", - "state" : { - "revision" : "0d91406ecd4d6c1c56275866f00508d9aeacc92a", - "version" : "1.2.0" - } - }, - { - "identity" : "osver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/brightdigit/OSVer", - "state" : { - "revision" : "448f170babc2f6c9897194a4b42719994639325d", - "version" : "1.0.0" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", - "version" : "1.6.2" + "revision" : "2549700b90dbc3204eaabb781dc103287694853c", + "version" : "0.0.2" } }, { @@ -42,17 +15,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", - "version" : "1.5.0" + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" } }, { - "identity" : "swift-atomics", + "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", + "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" } }, { @@ -64,6 +37,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749", + "version" : "1.0.0" + } + }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", @@ -87,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" } }, { @@ -96,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "7722cf8eac05c1f1b5b05895b04cfcc29576d9be", - "version" : "1.8.3" + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" } }, { @@ -110,12 +92,39 @@ } }, { - "identity" : "swiftsoup", + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } + }, + { + "identity" : "syndikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/SyndiKit.git", + "state" : { + "revision" : "f6f9cc8d1c905e67e66ba2822dd30299ead26867", + "version" : "0.8.0" + } + }, + { + "identity" : "xmlcoder", "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup.git", + "location" : "https://github.com/CoreOffice/XMLCoder", "state" : { - "revision" : "4206bc7b8bd9a4ff8e9511211e1b4bff979ef9c4", - "version" : "2.11.1" + "revision" : "5e1ada828d2618ecb79c974e03f79c8f4df90b71", + "version" : "0.18.0" } } ], diff --git a/Examples/Bushel/Package.swift b/Examples/CelestraCloud/Package.swift similarity index 66% rename from Examples/Bushel/Package.swift rename to Examples/CelestraCloud/Package.swift index 82ddaa16..8e2223e5 100644 --- a/Examples/Bushel/Package.swift +++ b/Examples/CelestraCloud/Package.swift @@ -1,5 +1,4 @@ // swift-tools-version: 6.2 -// The swift-tools-version declares the minimum version of Swift required to build this package. // swiftlint:disable explicit_acl explicit_top_level_acl @@ -78,32 +77,55 @@ let swiftSettings: [SwiftSetting] = [ ] let package = Package( - name: "Bushel", - platforms: [ - .macOS(.v14) - ], - products: [ - .executable(name: "bushel-images", targets: ["BushelImages"]) - ], - dependencies: [ - .package(name: "MistKit", path: "../.."), - .package(url: "https://github.com/brightdigit/IPSWDownloads.git", from: "1.0.0"), - .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0") - ], - targets: [ - .executableTarget( - name: "BushelImages", - dependencies: [ - .product(name: "MistKit", package: "MistKit"), - .product(name: "IPSWDownloads", package: "IPSWDownloads"), - .product(name: "SwiftSoup", package: "SwiftSoup"), - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log") - ], - swiftSettings: swiftSettings - ) - ] + name: "CelestraCloud", + platforms: [ + .macOS(.v26), + .iOS(.v26), + .tvOS(.v26), + .watchOS(.v26), + .visionOS(.v26) + ], + products: [ + .executable(name: "celestra-cloud", targets: ["CelestraCloud"]), + .library(name: "CelestraCloudKit", targets: ["CelestraCloudKit"]) + ], + dependencies: [ + .package(name: "MistKit", path: "../.."), + .package(url: "https://github.com/brightdigit/CelestraKit.git", from: "0.0.2"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package( + url: "https://github.com/apple/swift-configuration.git", + from: "1.0.0", + traits: ["CommandLineArguments"] + ) + ], + targets: [ + .target( + name: "CelestraCloudKit", + dependencies: [ + .product(name: "MistKit", package: "MistKit"), + .product(name: "CelestraKit", package: "CelestraKit"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Configuration", package: "swift-configuration") + ], + swiftSettings: swiftSettings + ), + .executableTarget( + name: "CelestraCloud", + dependencies: [ + .target(name: "CelestraCloudKit") + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "CelestraCloudTests", + dependencies: [ + .target(name: "CelestraCloudKit"), + .product(name: "MistKit", package: "MistKit"), + .product(name: "CelestraKit", package: "CelestraKit") + ], + swiftSettings: swiftSettings + ) + ] ) // swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Examples/CelestraCloud/README.md b/Examples/CelestraCloud/README.md new file mode 100644 index 00000000..d67d608a --- /dev/null +++ b/Examples/CelestraCloud/README.md @@ -0,0 +1,799 @@ +# CelestraCloud - RSS Reader with CloudKit Sync + +[![CelestraCloud](https://github.com/brightdigit/CelestraCloud/actions/workflows/CelestraCloud.yml/badge.svg)](https://github.com/brightdigit/CelestraCloud/actions/workflows/CelestraCloud.yml) +[![Swift 6.2](https://img.shields.io/badge/Swift-6.2-orange.svg)](https://swift.org) +[![Platform](https://img.shields.io/badge/platform-macOS%20Linux-lightgrey.svg)](https://www.apple.com/macos/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +CelestraCloud is a command-line RSS reader that demonstrates MistKit's query filtering and sorting features by managing RSS feeds in CloudKit's public database. + +## Features + +- **RSS Parsing with SyndiKit**: Parse RSS and Atom feeds using BrightDigit's SyndiKit library +- **Add RSS Feeds**: Parse and validate RSS feeds, then store metadata in CloudKit +- **Duplicate Detection**: Automatically detect and skip duplicate articles using GUID-based queries +- **Filtered Updates**: Query feeds using MistKit's `QueryFilter` API (by date and popularity) +- **Batch Operations**: Upload multiple articles efficiently using non-atomic operations +- **Server-to-Server Auth**: Demonstrates CloudKit authentication for backend services +- **Record Modification**: Uses MistKit's new public record modification APIs + +## Prerequisites + +1. **Apple Developer Account** with CloudKit access +2. **CloudKit Container** configured in Apple Developer Console +3. **Server-to-Server Key** generated for CloudKit access +4. **Swift 5.9+** and **macOS 13.0+** (required by SyndiKit) + +## CloudKit Setup + +You can set up the CloudKit schema either automatically using `cktool` (recommended) or manually through the CloudKit Dashboard. + +### Option 1: Automated Setup (Recommended) + +Use the provided script to automatically import the schema: + +```bash +# Set your CloudKit credentials +export CLOUDKIT_CONTAINER_ID="iCloud.com.brightdigit.Celestra" +export CLOUDKIT_TEAM_ID="YOUR_TEAM_ID" +export CLOUDKIT_ENVIRONMENT="development" + +# Run the setup script +./Scripts/setup-cloudkit-schema.sh +``` + +For detailed instructions, see [.claude/CLOUDKIT_SCHEMA_SETUP.md](./.claude/CLOUDKIT_SCHEMA_SETUP.md). + +### Option 2: Manual Setup + +#### 1. Create CloudKit Container + +1. Go to [Apple Developer Console](https://developer.apple.com) +2. Navigate to CloudKit Dashboard +3. Create a new container (e.g., `iCloud.com.brightdigit.Celestra`) + +#### 2. Configure Record Types + +In CloudKit Dashboard, create these record types in the **Public Database**: + +#### Feed Record Type +| Field Name | Field Type | Indexed | +|------------|------------|---------| +| feedURL | String | Yes (Queryable, Sortable) | +| title | String | Yes (Searchable) | +| description | String | No | +| totalAttempts | Int64 | No | +| successfulAttempts | Int64 | No | +| usageCount | Int64 | Yes (Queryable, Sortable) | +| lastAttempted | Date/Time | Yes (Queryable, Sortable) | +| isActive | Int64 | Yes (Queryable) | + +#### Article Record Type +| Field Name | Field Type | Indexed | +|------------|------------|---------| +| feedRecordName | String | Yes (Queryable, Sortable) | +| title | String | Yes (Searchable) | +| link | String | No | +| description | String | No | +| author | String | Yes (Queryable) | +| pubDate | Date/Time | Yes (Queryable, Sortable) | +| guid | String | Yes (Queryable, Sortable) | +| contentHash | String | Yes (Queryable) | +| fetchedAt | Date/Time | Yes (Queryable, Sortable) | +| expiresAt | Date/Time | Yes (Queryable, Sortable) | + +#### 3. Generate Server-to-Server Key + +1. In CloudKit Dashboard, go to **API Tokens** +2. Click **Server-to-Server Keys** +3. Generate a new key +4. Download the `.pem` file and save it securely +5. Note the **Key ID** (you'll need this) + +## Installation + +### 1. Clone Repository + +```bash +git clone https://github.com/brightdigit/CelestraCloud.git +cd CelestraCloud +``` + +### 2. Configure Environment + +```bash +# Copy the example environment file +cp .env.example .env + +# Edit .env with your CloudKit credentials +nano .env +``` + +Update `.env` with your values: + +```bash +CLOUDKIT_CONTAINER_ID=iCloud.com.brightdigit.Celestra +CLOUDKIT_KEY_ID=your-key-id-here +CLOUDKIT_PRIVATE_KEY_PATH=/path/to/eckey.pem +CLOUDKIT_ENVIRONMENT=development +``` + +### 3. Build + +```bash +swift build +# Or use the Makefile +make build +``` + +## Usage + +Source your environment variables before running commands: + +```bash +source .env +``` + +### Add a Feed + +Add a new RSS feed to CloudKit: + +```bash +swift run celestra-cloud add-feed https://example.com/feed.xml +``` + +Example output: +``` +🌐 Fetching RSS feed: https://example.com/feed.xml +✅ Found feed: Example Blog + Articles: 25 +✅ Feed added to CloudKit + Record Name: ABC123-DEF456-GHI789 + Zone: default +``` + +### Update Feeds + +Fetch and update all active RSS feeds from CloudKit. + +#### Basic Usage + +```bash +# Update all feeds with default settings +swift run celestra-cloud update + +# With custom rate limiting +swift run celestra-cloud update --update-delay 3.0 + +# Skip robots.txt checks (not recommended) +swift run celestra-cloud update --update-skip-robots-check +``` + +#### Filtering Options + +Use filters to selectively update feeds based on various criteria: + +**By Date:** +```bash +# Update only feeds last attempted before a specific date +swift run celestra-cloud update --update-last-attempted-before 2025-01-01T00:00:00Z +``` + +**By Popularity:** +```bash +# Update only popular feeds (minimum 10 subscribers) +swift run celestra-cloud update --update-min-popularity 10 +``` + +**By Failure Count:** +```bash +# Skip feeds with more than 5 consecutive failures +swift run celestra-cloud update --update-max-failures 5 +``` + +**Combined Filters:** +```bash +# Update popular feeds that haven't been updated recently +swift run celestra-cloud update \ + --update-last-attempted-before 2025-01-01T00:00:00Z \ + --update-min-popularity 5 \ + --update-delay 1.5 +``` + +#### Configuration Options + +All update options can be configured via environment variables or CLI arguments: + +| Option | Environment Variable | CLI Argument | Default | +|--------|---------------------|--------------|---------| +| Rate Limit | `UPDATE_DELAY=3.0` | `--update-delay 3.0` | `2.0` seconds | +| Skip Robots | `UPDATE_SKIP_ROBOTS_CHECK=true` | `--update-skip-robots-check` | `false` | +| Max Failures | `UPDATE_MAX_FAILURES=5` | `--update-max-failures 5` | None | +| Min Popularity | `UPDATE_MIN_POPULARITY=10` | `--update-min-popularity 10` | None | +| Date Filter | `UPDATE_LAST_ATTEMPTED_BEFORE=2025-01-01T00:00:00Z` | `--update-last-attempted-before 2025-01-01T00:00:00Z` | None | + +**Priority**: CLI arguments override environment variables. + +**Example with environment variables:** +```bash +# Set defaults in .env file +echo "UPDATE_DELAY=3.0" >> .env +echo "UPDATE_MAX_FAILURES=5" >> .env + +# Source and run +source .env +swift run celestra-cloud update + +# Or use mixed configuration +UPDATE_DELAY=2.0 swift run celestra-cloud update --update-delay 5.0 +# Uses 5.0 (CLI wins over ENV) +``` + +#### Example Output + +``` +🔄 Starting feed update... + ⏱️ Rate limit: 2.0 seconds between feeds + Filter: last attempted before 2025-01-01T00:00:00Z + Filter: minimum popularity 5 +📋 Querying feeds... +✅ Found 3 feed(s) to update + +[1/3] 📰 Example Blog + ✅ Fetched 25 articles + ℹ️ Skipped 20 duplicate(s) + ✅ Uploaded 5 new article(s) + +[2/3] 📰 Tech News + ✅ Fetched 15 articles + ℹ️ Skipped 10 duplicate(s) + ✅ Uploaded 5 new article(s) + +[3/3] 📰 Daily Updates + ✅ Fetched 10 articles + ℹ️ No new articles to upload + +✅ Update complete! + Success: 3 + Errors: 0 +``` + +### Clear All Data + +Delete all feeds and articles from CloudKit: + +```bash +swift run celestra-cloud clear --confirm +``` + +## How It Demonstrates MistKit Features + +### 1. Query Filtering (`QueryFilter`) + +The `update` command demonstrates filtering with date and numeric comparisons: + +```swift +// In CloudKitService+Celestra.swift +var filters: [QueryFilter] = [] + +// Date comparison filter +if let cutoff = lastAttemptedBefore { + filters.append(.lessThan("lastAttempted", .date(cutoff))) +} + +// Numeric comparison filter +if let minPop = minPopularity { + filters.append(.greaterThanOrEquals("usageCount", .int64(minPop))) +} +``` + +### 2. Query Sorting (`QuerySort`) + +Results are automatically sorted by popularity (descending): + +```swift +let records = try await queryRecords( + recordType: "Feed", + filters: filters.isEmpty ? nil : filters, + sortBy: [.descending("usageCount")], // Sort by popularity + limit: limit +) +``` + +### 3. Batch Operations + +Articles are uploaded in batches using non-atomic operations for better performance: + +```swift +// Non-atomic allows partial success +return try await modifyRecords(operations: operations, atomic: false) +``` + +### 4. Duplicate Detection + +Celestra automatically detects and skips duplicate articles during feed updates: + +```swift +// In UpdateCommand.swift +// 1. Extract GUIDs from fetched articles +let guids = articles.map { $0.guid } + +// 2. Query existing articles by GUID +let existingArticles = try await service.queryArticlesByGUIDs( + guids, + feedRecordName: recordName +) + +// 3. Filter out duplicates +let existingGUIDs = Set(existingArticles.map { $0.guid }) +let newArticles = articles.filter { !existingGUIDs.contains($0.guid) } + +// 4. Only upload new articles +if !newArticles.isEmpty { + _ = try await service.createArticles(newArticles) +} +``` + +#### How Duplicate Detection Works + +1. **GUID-Based Identification**: Each article has a unique GUID (Globally Unique Identifier) from the RSS feed +2. **Pre-Upload Query**: Before uploading, Celestra queries CloudKit for existing articles with the same GUIDs +3. **Content Hash Fallback**: Articles also include a SHA256 content hash for duplicate detection when GUIDs are unreliable +4. **Efficient Filtering**: Uses Set-based filtering for O(n) performance with large article counts + +This ensures you can run `update` multiple times without creating duplicate articles in CloudKit. + +### 5. Server-to-Server Authentication + +Demonstrates CloudKit authentication without user interaction: + +```swift +let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + pemString: privateKeyPEM +) + +let service = try CloudKitService( + containerIdentifier: containerID, + tokenManager: tokenManager, + environment: environment, + database: .public +) +``` + +## Architecture + +``` +Sources/Celestra/ +├── Models/ +│ └── BatchOperationResult.swift # Batch operation tracking +├── Services/ +│ ├── RSSFetcherService.swift # RSS parsing with SyndiKit +│ ├── CloudKitService+Celestra.swift # CloudKit operations +│ ├── CelestraError.swift # Error types +│ └── CelestraLogger.swift # Structured logging +├── Commands/ +│ ├── AddFeedCommand.swift # Add feed command +│ ├── UpdateCommand.swift # Update feeds command (demonstrates filters) +│ └── ClearCommand.swift # Clear data command +├── Extensions/ +│ ├── Feed+MistKit.swift # Feed ↔ CloudKit conversion +│ └── Article+MistKit.swift # Article ↔ CloudKit conversion +├── CelestraConfig.swift # CloudKit service factory +└── Celestra.swift # Main CLI entry point + +External Dependencies (from CelestraKit): +├── Feed.swift # Feed metadata model +├── Article.swift # Article model +├── RateLimiter.swift # Per-domain rate limiting +└── RobotsTxtService.swift # Robots.txt compliance checking +``` + +## Schema Design + +### Overview + +CelestraCloud uses CloudKit's public database with a carefully designed schema optimized for RSS feed aggregation and content discovery. The schema includes two record types (`Feed` and `Article`) with a mix of user-provided data, calculated fields, and server-managed metadata. + +### Record Types + +#### Feed Record Type + +Stores RSS feed metadata in the public database, shared across all users. + +**Core Metadata:** +- `feedURL` (String, Queryable+Sortable) - Unique RSS/Atom feed URL +- `title` (String, Searchable) - Feed title +- `description` (String) - Feed description/subtitle +- `category` (String, Queryable) - Content category +- `imageURL` (String) - Feed logo/icon URL +- `siteURL` (String) - Website home page URL +- `language` (String, Queryable) - ISO language code +- `tags` (List) - User-defined tags + +**Quality Indicators:** +- `isFeatured` (Int64, Queryable) - 1 if featured, 0 otherwise +- `isVerified` (Int64, Queryable) - 1 if verified/trusted, 0 otherwise +- `qualityScore` (Int64, Queryable+Sortable) - **CALCULATED** quality score (0-100) +- `subscriberCount` (Int64, Queryable+Sortable) - Number of subscribers + +**Timestamps:** +- `verifiedTimestamp` (Timestamp, Queryable+Sortable) - Last verification time +- `attemptedTimestamp` (Timestamp, Queryable+Sortable) - Last fetch attempt +- Note: Creation time uses CloudKit's built-in `createdTimestamp` field + +**Feed Characteristics (Calculated):** +- `updateFrequency` (Double) - **CALCULATED:** Average articles per day +- `minUpdateInterval` (Double) - **CALCULATED:** Minimum hours between requests + +**Server Metrics:** +- `totalAttempts` (Int64) - Total fetch attempts +- `successfulAttempts` (Int64) - Successful fetches +- `failureCount` (Int64) - Consecutive failures (reset on success) +- `lastFailureReason` (String) - Most recent error message +- `isActive` (Int64, Queryable) - 1 if active, 0 if disabled + +**HTTP Caching:** +- `etag` (String) - ETag for conditional requests +- `lastModified` (String) - Last-Modified header value + +#### Article Record Type + +Stores RSS article content in the public database. + +**Identity & Relationships:** +- `feedRecordName` (String, Queryable+Sortable) - Parent Feed recordName +- `guid` (String, Queryable+Sortable) - Article unique ID from RSS + +**Core Content:** +- `title` (String, Searchable) - Article title +- `excerpt` (String) - Summary/description +- `content` (String, Searchable) - Full HTML content +- `contentText` (String, Searchable) - **CALCULATED:** Plain text from HTML +- `author` (String, Queryable) - Author name +- `url` (String) - Article permalink +- `imageURL` (String) - Featured image URL (manually enriched) + +**Publishing Metadata:** +- `publishedTimestamp` (Timestamp, Queryable+Sortable) - Original publish date +- `fetchedTimestamp` (Timestamp, Queryable+Sortable) - When fetched from RSS +- `expiresTimestamp` (Timestamp, Queryable+Sortable) - **CALCULATED:** Cache expiration + +**Deduplication & Analysis (Calculated):** +- `contentHash` (String, Queryable) - **CALCULATED:** SHA256 composite key (title|url|guid) +- `wordCount` (Int64) - **CALCULATED:** Word count from contentText +- `estimatedReadingTime` (Int64) - **CALCULATED:** Minutes to read (wordCount / 200) + +**Enrichment Fields:** +- `language` (String, Queryable) - ISO language code (manually enriched) +- `tags` (List) - Content tags (manually enriched) + +### Calculated Fields + +The schema includes several calculated/derived fields that are computed during RSS feed processing: + +#### Feed Calculations + +**`qualityScore` (0-100):** +Composite metric balancing reliability, popularity, update consistency, and verification: + +``` +qualityScore = min(100, + (successRate × 40) + // 40 points: reliability + (subscriberBonus × 30) + // 30 points: popularity + (updateConsistency × 20) + // 20 points: update pattern + (verifiedBonus × 10) // 10 points: verification +) + +where: +- successRate = successfulAttempts / max(1, totalAttempts) +- subscriberBonus = min(10, log10(max(1, subscriberCount)) × 3) +- updateConsistency = calculated from updateFrequency deviation +- verifiedBonus = isVerified ? 10 : 0 +``` + +**`updateFrequency` (articles/day):** +``` +updateFrequency = articlesPublished / daysSinceFirstArticle +``` +Calculated during feed refresh, represents how often new articles appear. + +**`minUpdateInterval` (hours):** +``` +minUpdateInterval = max( + ttl_from_rss, // RSS tag if present + feedUpdateFrequency × 0.8, // 80% of average update frequency + 1.0 // Minimum 1 hour +) +``` +Respects feed's requested update rate for web etiquette. + +#### Article Calculations + +**`contentText`:** +``` +contentText = stripHTML(content).trimmed() +``` +Uses HTML parser to extract text, removes tags and scripts. + +**`contentHash`:** +``` +contentHash = SHA256("\(guid)|\(title)|\(url)") +``` +Composite hash for identifying content changes and duplicates. + +**`wordCount`:** +``` +wordCount = contentText.split(by: whitespace).count +``` + +**`estimatedReadingTime` (minutes):** +``` +estimatedReadingTime = max(1, wordCount / 200) +``` +Assumes 200 words per minute reading speed. + +**`expiresTimestamp`:** +``` +expiresTimestamp = fetchedTimestamp + (ttlDays × 24 × 3600) +``` +Defaults to 30 days unless specified. + +### CloudKit Security Model + +CelestraCloud uses a **server-managed public database** architecture with carefully designed permissions: + +**Permissions for Feed and Article:** +``` +GRANT READ TO "_world", +GRANT CREATE, WRITE TO "_icloud" +``` + +**Why this design?** + +1. **Public Read Access (`_world`):** + - All users can read the feed catalog and articles + - Enables content discovery across the platform + - No authentication required for browsing + +2. **Server-Only Write (`_icloud`):** + - Only server-to-server operations can create/modify feeds + - Prevents individual users from polluting the shared catalog + - Ensures content quality and consistency + - Uses CLI/backend with explicit credentials + +3. **No `_creator` Role:** + - Feeds are shared resources, not user-owned + - Prevents per-user feed duplication + - Eliminates ownership conflicts + - Simplifies permission model + +**Security Implications:** +- ✅ Public feeds remain readable by everyone +- ✅ Only authorized servers can modify content +- ✅ Individual users cannot claim ownership of shared feeds +- ✅ Prevents accidental data corruption +- ✅ Centralized content moderation + +**Server-to-Server Authentication:** +```swift +let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + pemString: privateKeyPEM +) + +let service = try CloudKitService( + containerIdentifier: containerID, + tokenManager: tokenManager, + environment: environment, + database: .public +) +``` + +### Error Handling Strategy + +CelestraCloud uses a **hybrid error handling approach** balancing CloudKit storage costs with debugging needs: + +**1. Inline Error Fields (CloudKit):** +```swift +// Feed record includes lightweight error tracking +"failureCount" INT64 // Consecutive failure count +"lastFailureReason" STRING // Most recent error message only +``` + +**Benefits:** +- ✅ Simple queries: "show feeds with failures" +- ✅ No additional lookups needed +- ✅ Minimal storage overhead + +**Limitations:** +- ❌ Only stores latest error +- ❌ No error history + +**2. Local Logging (CelestraLogger):** +```swift +// Detailed errors logged locally, not to CloudKit +CelestraLogger.errors.error("Failed to fetch feed: \(feedURL) - \(error)") +CelestraLogger.operations.info("Retrying after 60s...") +``` + +**Benefits:** +- ✅ Full error history for debugging +- ✅ Detailed stack traces and context +- ✅ No CloudKit storage costs +- ✅ Can integrate with external logging services + +**Why Not a Separate ErrorLog Record Type?** + +We considered creating an `ErrorLog` record type in CloudKit but opted against it: +- ❌ Additional CloudKit queries needed +- ❌ Increased storage costs for verbose logs +- ❌ Public database not ideal for error logs +- ❌ Better handled by external logging infrastructure + +**Recommendation:** +- Keep inline fields for quick error status checks +- Use CelestraLogger for detailed debugging +- For production, integrate with external logging (e.g., CloudWatch, Sentry) + +### Timestamp Field Naming + +All timestamp fields use a consistent `Timestamp` suffix to match CloudKit conventions: + +**Feed Timestamps:** +- `createdTimestamp` (CloudKit built-in) - When feed was created +- `verifiedTimestamp` - Last verification time +- `attemptedTimestamp` - Last fetch attempt + +**Article Timestamps:** +- `publishedTimestamp` - Original publication date +- `fetchedTimestamp` - When fetched from RSS feed +- `expiresTimestamp` - Cache expiration time + +**Benefits:** +- ✅ Matches CloudKit's `createdTimestamp` and `modifiedTimestamp` pattern +- ✅ Consistent suffix makes fields easily recognizable +- ✅ Clearer than mixed `*At`, `*Date`, `last*` patterns +- ✅ Eliminated redundant `addedAt` field + +### Schema Migration Notes + +**Breaking Changes from v0.x:** + +The schema was refactored for consistency and CloudKit best practices: + +1. **Removed Fields:** + - `addedAt` → Use CloudKit's `createdTimestamp` + +2. **Renamed Fields:** + - `lastVerified` → `verifiedTimestamp` + - `lastAttempted` → `attemptedTimestamp` + - `publishedDate` → `publishedTimestamp` + - `fetchedAt` → `fetchedTimestamp` + - `expiresAt` → `expiresTimestamp` + +**Migration Required:** If you have existing CloudKit records, you'll need to migrate field data or recreate the database. + +## Dependencies + +CelestraCloud builds upon several key dependencies: + +### CelestraKit +[CelestraKit](https://github.com/brightdigit/CelestraKit) provides shared models and web etiquette services: +- **Feed & Article Models**: Core data structures for RSS feed metadata and articles +- **RateLimiter**: Actor-based per-domain rate limiting for respectful web crawling +- **RobotsTxtService**: Robots.txt parsing and compliance checking + +This separation allows the models and services to be reused across the Celestra ecosystem (future mobile apps, additional CLI tools, etc.). + +### MistKit +CloudKit Web Services wrapper providing query filtering, sorting, and record modification APIs. + +### SyndiKit +RSS and Atom feed parsing library from BrightDigit. + +### Swift Packages +- **ArgumentParser**: Command-line interface framework +- **Logging**: Structured logging infrastructure + +## Development + +### Using the Makefile + +CelestraCloud includes a comprehensive Makefile for common development tasks: + +```bash +# Install development dependencies (SwiftLint, SwiftFormat, etc.) +make install + +# Build the project +make build + +# Run unit tests +make test + +# Run linters +make lint + +# Auto-format code +make format + +# Run the CLI (requires .env sourced) +make run + +# Deploy CloudKit schema +make setup-cloudkit + +# Clean build artifacts +make clean +``` + +Run `make help` to see all available targets. + +### Running Tests + +```bash +# Run all tests +make test + +# Or use Swift directly +swift test +``` + +The test suite includes 22 local tests across 3 test suites: +- Feed+MistKitTests (7 tests) +- Article+MistKitTests (6 tests) +- BatchOperationResultTests (9 tests) + +Note: RateLimiter and RobotsTxtService tests (19 tests) are maintained in the CelestraKit package. + +### Code Quality + +```bash +# Run SwiftLint +make lint + +# Auto-format code with SwiftFormat +make format +``` + +The project enforces strict code quality standards with 90+ SwiftLint rules and comprehensive SwiftFormat configuration. + +## Documentation + +### Project Guides + +- **[CLAUDE.md](./CLAUDE.md)** - Guidance for AI agents working with this codebase +- **[CHANGELOG.md](./CHANGELOG.md)** - Release notes and version history +- **[.claude/IMPLEMENTATION_NOTES.md](./.claude/IMPLEMENTATION_NOTES.md)** - Design decisions and architectural patterns +- **[.claude/AI_SCHEMA_WORKFLOW.md](./.claude/AI_SCHEMA_WORKFLOW.md)** - CloudKit schema design workflow for AI agents +- **[.claude/CLOUDKIT_SCHEMA_SETUP.md](./.claude/CLOUDKIT_SCHEMA_SETUP.md)** - CloudKit schema deployment instructions +- **[.claude/PRD.md](./.claude/PRD.md)** - Product Requirements Document for v1.0.0 release + +## Troubleshooting + +### Authentication Errors + +- Verify your Key ID is correct +- Ensure the private key file exists and is readable +- Check that the container ID matches your CloudKit container + +### Missing Record Types + +- Make sure you created the record types in CloudKit Dashboard +- Verify you're using the correct database (public) +- Check the environment setting (development vs production) + +### Build Errors + +- Ensure Swift 5.9+ is installed: `swift --version` +- Clean and rebuild: `swift package clean && swift build` +- Update dependencies: `swift package update` + +## License + +MIT License - See [LICENSE](./LICENSE) for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/Examples/CelestraCloud/Scripts/header.sh b/Examples/CelestraCloud/Scripts/header.sh new file mode 100755 index 00000000..3b05882e --- /dev/null +++ b/Examples/CelestraCloud/Scripts/header.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template +header_template="// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +//" + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files in the Generated directory + if [[ "$file" == *"/Generated/"* ]]; then + echo "Skipping $file (generated file)" + continue + fi + + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + filename=$(basename "$file") + header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." \ No newline at end of file diff --git a/Examples/CelestraCloud/Scripts/lint.sh b/Examples/CelestraCloud/Scripts/lint.sh new file mode 100755 index 00000000..0808cbd9 --- /dev/null +++ b/Examples/CelestraCloud/Scripts/lint.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + if [ "$LINT_MODE" = "STRICT" ]; then + "$@" || ERRORS=$((ERRORS + 1)) + else + "$@" + fi +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Detect OS and set paths accordingly +if [ "$(uname)" = "Darwin" ]; then + DEFAULT_MINT_PATH="/opt/homebrew/bin/mint" +elif [ "$(uname)" = "Linux" ] && [ -n "$GITHUB_ACTIONS" ]; then + DEFAULT_MINT_PATH="$GITHUB_WORKSPACE/Mint/.mint/bin/mint" +elif [ "$(uname)" = "Linux" ]; then + DEFAULT_MINT_PATH="/usr/local/bin/mint" +else + echo "Unsupported operating system" + exit 1 +fi + +# Use environment MINT_CMD if set, otherwise use default path +MINT_CMD=${MINT_CMD:-$DEFAULT_MINT_PATH} + +export MINT_PATH="$PACKAGE_DIR/.mint" +MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" +MINT_RUN="$MINT_CMD run $MINT_ARGS" + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="--strict" + STRINGSLINT_OPTIONS="--config .strict.stringslint.yml" +else + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="" + STRINGSLINT_OPTIONS="--config .stringslint.yml" +fi + +pushd $PACKAGE_DIR +run_command $MINT_CMD bootstrap -m Mintfile + +if [ -z "$CI" ]; then + run_command $MINT_RUN swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + run_command $MINT_RUN swiftlint --fix +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command $MINT_RUN swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + run_command $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS + # Check for compilation errors + run_command swift build --build-tests +fi + +$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "CelestraCloud" + +# Generated files now automatically include ignore directives via OpenAPI generator configuration + +if [ -z "$CI" ]; then + run_command $MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Examples/Celestra/Scripts/setup-cloudkit-schema.sh b/Examples/CelestraCloud/Scripts/setup-cloudkit-schema.sh similarity index 97% rename from Examples/Celestra/Scripts/setup-cloudkit-schema.sh rename to Examples/CelestraCloud/Scripts/setup-cloudkit-schema.sh index 0d5ce7a0..cedc9197 100755 --- a/Examples/Celestra/Scripts/setup-cloudkit-schema.sh +++ b/Examples/CelestraCloud/Scripts/setup-cloudkit-schema.sh @@ -131,8 +131,9 @@ if xcrun cktool import-schema \ echo -e "${GREEN}✓✓✓ Schema import successful! ✓✓✓${NC}" echo "" echo "Your CloudKit container now has the following record types:" - echo " • Feed" - echo " • Article" + echo " • Feed (public database)" + echo " • Article (public database)" + echo " • FeedSubscription (public database)" echo "" echo "Next steps:" echo " 1. Get your Server-to-Server Key:" diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift new file mode 100644 index 00000000..2eae8c7f --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift @@ -0,0 +1,100 @@ +// +// Celestra.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +@main +internal enum Celestra { + internal static func main() async { + let args = Array(CommandLine.arguments.dropFirst()) + + guard let command = args.first else { + printUsage() + exit(1) + } + + do { + try await runCommand(command, args: Array(args.dropFirst())) + } catch { + print("Error: \(error)") + exit(1) + } + } + + private static func runCommand(_ command: String, args: [String]) async throws { + switch command { + case "add-feed": + try await AddFeedCommand.run(args: args) + case "update": + try await UpdateCommand.run() + case "clear": + try await ClearCommand.run(args: args) + case "help", "--help", "-h": + printUsage() + default: + print("Unknown command: \(command)") + printUsage() + exit(1) + } + } + + internal static func printUsage() { + print( + """ + celestra-cloud - RSS reader that syncs to CloudKit public database + + USAGE: + celestra-cloud [options] + + COMMANDS: + add-feed Add a new RSS feed to CloudKit + update [options] Fetch and update RSS feeds + clear --confirm Delete all feeds and articles + + UPDATE OPTIONS: + --update-delay Delay between feeds (default: 2.0) + --update-skip-robots-check Skip robots.txt checking + --update-max-failures Skip feeds above failure threshold + --update-min-popularity Only update popular feeds + --update-last-attempted-before Only update feeds before date + + CLOUDKIT OPTIONS (via environment variables): + CLOUDKIT_CONTAINER_ID CloudKit container identifier + CLOUDKIT_KEY_ID Server-to-Server key ID + CLOUDKIT_PRIVATE_KEY_PATH Path to .pem private key file + CLOUDKIT_ENVIRONMENT Either 'development' or 'production' + + EXAMPLES: + celestra-cloud add-feed https://example.com/feed.xml + celestra-cloud update --update-delay 3.0 + celestra-cloud clear --confirm + """ + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift new file mode 100644 index 00000000..9d6af91b --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift @@ -0,0 +1,90 @@ +// +// AddFeedCommand.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraCloudKit +import CelestraKit +import Foundation +import MistKit + +// MARK: - Supporting Types + +internal struct ExitError: Error {} + +// MARK: - Main Type + +internal enum AddFeedCommand { + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func run(args: [String]) async throws { + guard let feedURL = args.first else { + print("Error: Missing feed URL") + print("Usage: celestra-cloud add-feed ") + throw ExitError() + } + + print("🌐 Fetching RSS feed: \(feedURL)") + + // 1. Validate URL + guard let url = URL(string: feedURL) else { + print("Error: Invalid feed URL") + throw ExitError() + } + + // 2. Fetch RSS content to validate and extract title + let fetcher = RSSFetcherService(userAgent: .cloud(build: 1)) + let response = try await fetcher.fetchFeed(from: url) + + guard let feedData = response.feedData else { + print("Error: Feed was not modified (unexpected)") + throw ExitError() + } + + print("✅ Found feed: \(feedData.title)") + print(" Articles: \(feedData.items.count)") + + // 3. Load configuration and create CloudKit service + let loader = ConfigurationLoader() + let config = try await loader.loadConfiguration() + let validatedCloudKit = try config.cloudkit.validated() + let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + + // 4. Create Feed record with initial metadata + let feed = Feed( + feedURL: feedURL, + title: feedData.title, + description: feedData.description, + etag: response.etag, + lastModified: response.lastModified, + minUpdateInterval: feedData.minUpdateInterval + ) + let record = try await service.createFeed(feed) + + print("✅ Feed added to CloudKit") + print(" Record Name: \(record.recordName)") + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift new file mode 100644 index 00000000..1a8fdc0c --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift @@ -0,0 +1,70 @@ +// +// ClearCommand.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraCloudKit +import CelestraKit +import Foundation +import MistKit + +internal enum ClearCommand { + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func run(args: [String]) async throws { + // Require confirmation + let hasConfirm = args.contains("--confirm") + + if !hasConfirm { + print("⚠️ This will DELETE ALL feeds and articles from CloudKit!") + print(" Run with --confirm to proceed") + print("") + print(" Example: celestra-cloud clear --confirm") + return + } + + print("🗑️ Clearing all data from CloudKit...") + + // Load configuration and create CloudKit service + let loader = ConfigurationLoader() + let config = try await loader.loadConfiguration() + let validatedCloudKit = try config.cloudkit.validated() + let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + + // Delete articles first (to avoid orphans) + print("📋 Deleting articles...") + let articleService = ArticleCloudKitService(recordOperator: service) + try await articleService.deleteAllArticles() + print("✅ Articles deleted") + + // Delete feeds + print("📋 Deleting feeds...") + try await service.deleteAllFeeds() + print("✅ Feeds deleted") + + print("\n✅ All data cleared!") + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift new file mode 100644 index 00000000..73fe7be5 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift @@ -0,0 +1,310 @@ +// +// UpdateCommand.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraCloudKit +import CelestraKit +import Foundation +import MistKit + +/// Tracks update operation statistics +private struct UpdateSummary { + var successCount = 0 + var errorCount = 0 + var skippedCount = 0 + var notModifiedCount = 0 + var articlesCreated = 0 + var articlesUpdated = 0 + + mutating func record(_ result: FeedUpdateResult) { + switch result { + case .success(let created, let updated): + successCount += 1 + articlesCreated += created + articlesUpdated += updated + case .notModified: + notModifiedCount += 1 + case .skipped: + skippedCount += 1 + case .error: + errorCount += 1 + } + } +} + +internal enum UpdateCommand { + @available(macOS 13.0, *) + internal static func run() async throws { + let startTime = Date() + let loader = ConfigurationLoader() + let config = try await loader.loadConfiguration() + + printStartupInfo(config: config) + + let processor = try createProcessor(config: config) + let feeds = try await queryFeeds(config: config, processor: processor) + + print("✅ Found \(feeds.count) feed(s) to update") + + let (summary, feedResults) = await processFeeds(feeds, processor: processor) + let endTime = Date() + + printSummary(feeds: feeds, summary: summary) + + // Write JSON report if configured + if let jsonPath = config.update.jsonOutputPath { + try writeJSONReport( + config: config, + summary: summary, + feedResults: feedResults, + startTime: startTime, + endTime: endTime, + path: jsonPath + ) + } + + // Fail if any errors occurred + if summary.errorCount > 0 { + throw UpdateCommandError(errorCount: summary.errorCount) + } + } + + private static func printStartupInfo(config: CelestraConfiguration) { + print("🔄 Starting feed update...") + print(" ⏱️ Rate limit: \(config.update.delay) seconds between feeds") + if config.update.skipRobotsCheck { + print(" ⚠️ Skipping robots.txt checks") + } + + if let date = config.update.lastAttemptedBefore { + let formatter = ISO8601DateFormatter() + print(" Filter: last attempted before \(formatter.string(from: date))") + } + if let minPop = config.update.minPopularity { + print(" Filter: minimum popularity \(minPop)") + } + if let maxFail = config.update.maxFailures { + print(" Filter: maximum failures \(maxFail)") + } + if let limit = config.update.limit { + print(" Limit: maximum \(limit) feeds") + } + } + + @available(macOS 13.0, *) + private static func createProcessor( + config: CelestraConfiguration + ) throws -> FeedUpdateProcessor { + let validatedCloudKit = try config.cloudkit.validated() + let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + let fetcher = RSSFetcherService(userAgent: .cloud(build: 1)) + let robotsService = RobotsTxtService(userAgent: .cloud(build: 1)) + let rateLimiter = RateLimiter(defaultDelay: config.update.delay) + + // Create ArticleSyncService + let articleService = ArticleCloudKitService(recordOperator: service) + let articleSync = ArticleSyncService(articleService: articleService) + + return FeedUpdateProcessor( + service: service, + fetcher: fetcher, + robotsService: robotsService, + rateLimiter: rateLimiter, + skipRobotsCheck: config.update.skipRobotsCheck, + articleSync: articleSync + ) + } + + @available(macOS 13.0, *) + private static func queryFeeds( + config: CelestraConfiguration, + processor: FeedUpdateProcessor + ) async throws -> [Feed] { + print("📋 Querying feeds...") + + var feeds = try await processor.service.queryFeeds( + lastAttemptedBefore: config.update.lastAttemptedBefore, + minPopularity: config.update.minPopularity + ) + + if let maxFail = config.update.maxFailures { + feeds = feeds.filter { $0.failureCount <= maxFail } + } + + if let limit = config.update.limit { + feeds = Array(feeds.prefix(limit)) + } + + return feeds + } + + @available(macOS 13.0, *) + private static func processFeeds( + _ feeds: [Feed], + processor: FeedUpdateProcessor + ) async -> (UpdateSummary, [UpdateReport.FeedResult]) { + var summary = UpdateSummary() + var feedResults: [UpdateReport.FeedResult] = [] + + for (index, feed) in feeds.enumerated() { + print("\n[\(index + 1)/\(feeds.count)] Updating: \(feed.title)") + print(" URL: \(feed.feedURL)") + + let feedStartTime = Date() + + guard let url = URL(string: feed.feedURL) else { + print(" ❌ Invalid URL") + summary.errorCount += 1 + feedResults.append( + UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: "error", + articlesCreated: 0, + articlesUpdated: 0, + duration: Date().timeIntervalSince(feedStartTime), + error: "Invalid URL" + ) + ) + continue + } + + let result = await processor.processFeed(feed, url: url) + summary.record(result) + + let feedEndTime = Date() + let feedResult = createFeedResult( + feed: feed, + result: result, + duration: feedEndTime.timeIntervalSince(feedStartTime) + ) + feedResults.append(feedResult) + } + + return (summary, feedResults) + } + + private static func createFeedResult( + feed: Feed, + result: FeedUpdateResult, + duration: TimeInterval + ) -> UpdateReport.FeedResult { + switch result { + case .success(let created, let updated): + return UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: "success", + articlesCreated: created, + articlesUpdated: updated, + duration: duration, + error: nil + ) + case .notModified: + return UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: "notModified", + articlesCreated: 0, + articlesUpdated: 0, + duration: duration, + error: nil + ) + case .skipped(let reason): + return UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: "skipped", + articlesCreated: 0, + articlesUpdated: 0, + duration: duration, + error: reason + ) + case .error(let message): + return UpdateReport.FeedResult( + feedURL: feed.feedURL, + recordName: feed.recordName ?? "unknown", + status: "error", + articlesCreated: 0, + articlesUpdated: 0, + duration: duration, + error: message + ) + } + } + + private static func writeJSONReport( + config: CelestraConfiguration, + summary: UpdateSummary, + feedResults: [UpdateReport.FeedResult], + startTime: Date, + endTime: Date, + path: String + ) throws { + let report = UpdateReport( + startTime: startTime, + endTime: endTime, + configuration: UpdateReport.UpdateConfiguration( + delay: config.update.delay, + skipRobotsCheck: config.update.skipRobotsCheck, + maxFailures: config.update.maxFailures, + minPopularity: config.update.minPopularity, + limit: config.update.limit, + environment: config.cloudkit.environment == .production ? "production" : "development" + ), + summary: UpdateReport.Summary( + totalFeeds: summary.successCount + summary.errorCount + + summary.skippedCount + summary.notModifiedCount, + successCount: summary.successCount, + errorCount: summary.errorCount, + skippedCount: summary.skippedCount, + notModifiedCount: summary.notModifiedCount, + articlesCreated: summary.articlesCreated, + articlesUpdated: summary.articlesUpdated + ), + feeds: feedResults + ) + + try report.writeJSON(to: path) + print("📄 JSON report written to: \(path)") + } + + private static func printSummary(feeds: [Feed], summary: UpdateSummary) { + print("\n" + String(repeating: "─", count: 50)) + print("📊 Update Summary") + print(" Total feeds: \(feeds.count)") + print(" ✅ Successful: \(summary.successCount)") + print(" ❌ Errors: \(summary.errorCount)") + print(" ⏭️ Skipped (robots.txt): \(summary.skippedCount)") + print(" ℹ️ Not modified (304): \(summary.notModifiedCount)") + if summary.articlesCreated > 0 || summary.articlesUpdated > 0 { + print(" 📝 Articles created: \(summary.articlesCreated)") + print(" 📝 Articles updated: \(summary.articlesUpdated)") + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift new file mode 100644 index 00000000..d7669e30 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift @@ -0,0 +1,44 @@ +// +// UpdateCommandError.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Errors specific to feed update operations +internal struct UpdateCommandError: LocalizedError { + /// Number of feeds that encountered errors during update + let errorCount: Int + + var errorDescription: String? { + "\(errorCount) feed(s) encountered errors during update" + } + + var recoverySuggestion: String? { + "Review error messages above for details and check CloudKit connectivity" + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Documentation.docc/Documentation.md b/Examples/CelestraCloud/Sources/CelestraCloud/Documentation.docc/Documentation.md new file mode 100644 index 00000000..660c2edb --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Documentation.docc/Documentation.md @@ -0,0 +1,136 @@ +# ``Celestra`` + +CelestraCloud - A command-line RSS reader with CloudKit sync demonstrating MistKit integration. + +## Overview + +CelestraCloud is a production-ready command-line RSS reader that showcases MistKit's CloudKit Web Services integration capabilities. It manages RSS feeds in CloudKit's public database while implementing comprehensive web etiquette best practices including rate limiting, robots.txt compliance, and conditional HTTP requests. + +### Key Features + +- **RSS Feed Management** - Parse and store RSS feeds using SyndiKit +- **CloudKit Integration** - Demonstrate MistKit's query filtering and sorting APIs +- **Web Etiquette** - Respectful crawling with rate limiting and robots.txt compliance +- **Batch Operations** - Efficient article uploads with chunking and duplicate detection +- **Server-to-Server Auth** - CloudKit authentication for backend services + +## Topics + +### Commands + +- ``AddFeedCommand`` +- ``UpdateCommand`` +- ``ClearCommand`` + +### Local Services + +- ``RSSFetcherService`` +- ``CelestraLogger`` + +### External Services (from CelestraKit) + +CelestraCloud uses `RateLimiter` and `RobotsTxtService` from the CelestraKit package for web etiquette features. + +### Models + +- ``BatchOperationResult`` + +### Configuration + +- ``CelestraConfig`` +- ``CelestraError`` + +## Getting Started + +### Prerequisites + +1. **Apple Developer Account** with CloudKit access +2. **CloudKit Container** configured in Apple Developer Console +3. **Server-to-Server Key** generated for CloudKit access +4. **Swift 6.2+** and **macOS 26+** + +### Installation + +```bash +git clone https://github.com/brightdigit/CelestraCloud.git +cd CelestraCloud +make install +make build +``` + +### Configuration + +Create a `.env` file with your CloudKit credentials: + +```bash +CLOUDKIT_CONTAINER_ID=iCloud.com.brightdigit.Celestra +CLOUDKIT_KEY_ID=your-key-id-here +CLOUDKIT_PRIVATE_KEY_PATH=/path/to/eckey.pem +CLOUDKIT_ENVIRONMENT=development +``` + +### Usage + +```bash +# Source environment variables +source .env + +# Add a feed +swift run celestra-cloud add-feed https://example.com/feed.xml + +# Update feeds with filters +swift run celestra-cloud update --min-popularity 10 + +# Clear all data +swift run celestra-cloud clear --confirm +``` + +## Architecture + +CelestraCloud demonstrates several key MistKit patterns: + +### Query Filtering + +Uses MistKit's `QueryFilter` API for flexible CloudKit queries: + +```swift +var filters: [QueryFilter] = [] +filters.append(.lessThan("attemptedTimestamp", .date(cutoffDate))) +filters.append(.greaterThanOrEquals("subscriberCount", .int64(minPopularity))) +``` + +### Field Mapping + +Direct field mapping pattern for CloudKit record conversion: + +```swift +func toFieldsDict() -> [String: FieldValue] { + var fields: [String: FieldValue] = [ + "title": .string(title), + "isActive": .int64(isActive ? 1 : 0) + ] + return fields +} +``` + +### Batch Operations + +Efficient article uploads with chunking: + +```swift +let batches = articles.chunked(into: 10) +for batch in batches { + let result = try await createArticles(batch) + overallResult.append(result) +} +``` + +## Web Etiquette + +CelestraCloud implements comprehensive web etiquette best practices including rate limiting (configurable delays, default 2s per domain), robots.txt compliance (respects robots.txt rules for all feeds), conditional requests (uses If-Modified-Since/ETag headers), failure tracking (exponential backoff for failed feeds), and TTL respect (honors feed update intervals). + +## See Also + +- [CelestraCloud Repository](https://github.com/brightdigit/CelestraCloud) +- [MistKit Documentation](https://github.com/brightdigit/MistKit) +- [SyndiKit Documentation](https://github.com/brightdigit/SyndiKit) diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift new file mode 100644 index 00000000..b1a2cca6 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift @@ -0,0 +1,217 @@ +// +// FeedUpdateProcessor.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraCloudKit +import CelestraKit +import Foundation +import MistKit + +/// Processes individual feed updates +@available(macOS 13.0, *) +internal struct FeedUpdateProcessor { + internal let service: CloudKitService + private let fetcher: RSSFetcherService + private let robotsService: RobotsTxtService + private let rateLimiter: RateLimiter + private let skipRobotsCheck: Bool + private let articleSync: ArticleSyncService + private let metadataBuilder: FeedMetadataBuilder + + internal init( + service: CloudKitService, + fetcher: RSSFetcherService, + robotsService: RobotsTxtService, + rateLimiter: RateLimiter, + skipRobotsCheck: Bool, + articleSync: ArticleSyncService, + metadataBuilder: FeedMetadataBuilder = FeedMetadataBuilder() + ) { + self.service = service + self.fetcher = fetcher + self.robotsService = robotsService + self.rateLimiter = rateLimiter + self.skipRobotsCheck = skipRobotsCheck + self.articleSync = articleSync + self.metadataBuilder = metadataBuilder + } + + /// Process a single feed update with comprehensive web etiquette and error handling. + /// + /// ## Thread Safety + /// + /// This method processes feeds sequentially with no race conditions: + /// - All `await` operations are chained sequentially (no concurrent execution) + /// - GUID-based deduplication prevents duplicate article creation + /// - Each feed operates on isolated data with no shared mutable state + /// - Rate limiting is managed by the thread-safe `RateLimiter` actor + /// + /// Multiple feeds can be processed concurrently by calling this method in parallel, + /// but each individual feed update is internally sequential and safe. + /// + /// - Parameters: + /// - feed: The feed to update + /// - url: The RSS feed URL to fetch + /// - Returns: Result indicating success, error, or skipped status + internal func processFeed(_ feed: Feed, url: URL) async -> FeedUpdateResult { + guard let recordName = feed.recordName else { + print(" ❌ Feed missing recordName") + return .error(message: "Feed missing recordName") + } + + if !skipRobotsCheck { + do { + let isAllowed = try await robotsService.isAllowed(url) + if !isAllowed { + print(" ⏭️ Skipped: robots.txt disallows") + return .skipped(reason: "robots.txt disallows") + } + } catch { + print(" ⚠️ Could not check robots.txt: \(error.localizedDescription)") + } + } + + await rateLimiter.waitIfNeeded(for: url) + return await fetchAndProcess(feed: feed, url: url, recordName: recordName) + } + + private func fetchAndProcess( + feed: Feed, + url: URL, + recordName: String + ) async -> FeedUpdateResult { + let totalAttempts = feed.totalAttempts + 1 + + do { + let response = try await fetcher.fetchFeed( + from: url, + lastModified: feed.lastModified, + etag: feed.etag + ) + + guard let feedData = response.feedData else { + print(" ℹ️ Not modified (304)") + let metadata = metadataBuilder.buildNotModifiedMetadata( + feed: feed, + response: response, + totalAttempts: totalAttempts + ) + _ = await updateFeedMetadata( + feed: feed, + recordName: recordName, + metadata: metadata, + articlesCreated: 0, + articlesUpdated: 0 + ) + // For not modified, always return notModified regardless of metadata update result + return .notModified + } + + print(" ✅ Fetched: \(feedData.items.count) articles") + + // Sync articles via ArticleSyncService + let syncResult = try await articleSync.syncArticles( + items: feedData.items, + feedRecordName: recordName + ) + + // Print results for user feedback + print(" 📝 New: \(syncResult.newCount), Modified: \(syncResult.modifiedCount)") + if syncResult.created.failureCount > 0 { + print(" ⚠️ Failed to create \(syncResult.created.failureCount) articles") + } + if syncResult.updated.failureCount > 0 { + print(" ⚠️ Failed to update \(syncResult.updated.failureCount) articles") + } + + let metadata = metadataBuilder.buildSuccessMetadata( + feedData: feedData, + response: response, + feed: feed, + totalAttempts: totalAttempts + ) + return await updateFeedMetadata( + feed: feed, + recordName: recordName, + metadata: metadata, + articlesCreated: syncResult.created.successCount, + articlesUpdated: syncResult.updated.successCount + ) + } catch { + print(" ❌ Error: \(error.localizedDescription)") + let metadata = metadataBuilder.buildErrorMetadata( + feed: feed, + totalAttempts: totalAttempts + ) + _ = await updateFeedMetadata( + feed: feed, + recordName: recordName, + metadata: metadata, + articlesCreated: 0, + articlesUpdated: 0 + ) + return .error(message: error.localizedDescription) + } + } + + private func updateFeedMetadata( + feed: Feed, + recordName: String, + metadata: FeedMetadataUpdate, + articlesCreated: Int, + articlesUpdated: Int + ) async -> FeedUpdateResult { + let updatedFeed = Feed( + recordName: feed.recordName, + recordChangeTag: feed.recordChangeTag, + feedURL: feed.feedURL, + title: metadata.title, + description: metadata.description, + isFeatured: feed.isFeatured, + isVerified: feed.isVerified, + subscriberCount: feed.subscriberCount, + totalAttempts: metadata.totalAttempts, + successfulAttempts: metadata.successfulAttempts, + lastAttempted: Date(), + isActive: feed.isActive, + etag: metadata.etag, + lastModified: metadata.lastModified, + failureCount: metadata.failureCount, + minUpdateInterval: metadata.minUpdateInterval + ) + do { + _ = try await service.updateFeed(recordName: recordName, feed: updatedFeed) + return metadata.failureCount == 0 + ? .success(articlesCreated: articlesCreated, articlesUpdated: articlesUpdated) + : .error(message: "Feed update had failures") + } catch { + print(" ⚠️ Failed to update feed metadata: \(error.localizedDescription)") + return .error(message: "Failed to update feed metadata: \(error.localizedDescription)") + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift new file mode 100644 index 00000000..c03e0c4b --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift @@ -0,0 +1,57 @@ +// +// FeedUpdateResult.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Result of processing a single feed update +internal enum FeedUpdateResult: Sendable, Equatable { + case success(articlesCreated: Int, articlesUpdated: Int) + case notModified + case skipped(reason: String) + case error(message: String) + + /// Simple status for backward compatibility + internal var simpleStatus: SimpleStatus { + switch self { + case .success: + return .success + case .notModified: + return .notModified + case .skipped: + return .skipped + case .error: + return .error + } + } + + internal enum SimpleStatus { + case success + case notModified + case skipped + case error + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift new file mode 100644 index 00000000..88cb83eb --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift @@ -0,0 +1,123 @@ +// +// CelestraConfig.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit + +// MARK: - Configuration Error + +/// Custom error for configuration issues (library-compatible) +public struct ConfigurationError: LocalizedError { + /// The error message describing what went wrong. + public let message: String + + /// A localized description of the error. + public var errorDescription: String? { + message + } + + /// Creates a new configuration error. + /// + /// - Parameter message: The error message describing what went wrong. + public init(_ message: String) { + self.message = message + } +} + +// MARK: - Shared Configuration + +/// Shared configuration helper for creating CloudKit service +public enum CelestraConfig { + /// Create CloudKit service from validated configuration + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + public static func createCloudKitService(from config: ValidatedCloudKitConfiguration) throws + -> CloudKitService + { + // Read private key from file + let privateKeyPEM = try String(contentsOfFile: config.privateKeyPath, encoding: .utf8) + + // Create token manager for server-to-server authentication + let tokenManager = try ServerToServerAuthManager( + keyID: config.keyID, + pemString: privateKeyPEM + ) + + // Create and return CloudKit service + return try CloudKitService( + containerIdentifier: config.containerID, + tokenManager: tokenManager, + environment: config.environment, + database: .public + ) + } + + /// Create CloudKit service from environment variables + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + @available( + *, deprecated, message: "Use ConfigurationLoader with createCloudKitService(from:) instead" + ) + public static func createCloudKitService() throws -> CloudKitService { + // Validate required environment variables + guard let containerID = ProcessInfo.processInfo.environment["CLOUDKIT_CONTAINER_ID"] else { + throw ConfigurationError("CLOUDKIT_CONTAINER_ID environment variable required") + } + + guard let keyID = ProcessInfo.processInfo.environment["CLOUDKIT_KEY_ID"] else { + throw ConfigurationError("CLOUDKIT_KEY_ID environment variable required") + } + + guard let privateKeyPath = ProcessInfo.processInfo.environment["CLOUDKIT_PRIVATE_KEY_PATH"] + else { + throw ConfigurationError("CLOUDKIT_PRIVATE_KEY_PATH environment variable required") + } + + // Read private key from file + let privateKeyPEM = try String(contentsOfFile: privateKeyPath, encoding: .utf8) + + // Determine environment (development or production) + let environment: MistKit.Environment = + ProcessInfo.processInfo.environment["CLOUDKIT_ENVIRONMENT"] == "production" + ? .production + : .development + + // Create token manager for server-to-server authentication + let tokenManager = try ServerToServerAuthManager( + keyID: keyID, + pemString: privateKeyPEM + ) + + // Create and return CloudKit service + return try CloudKitService( + containerIdentifier: containerID, + tokenManager: tokenManager, + environment: environment, + database: .public + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift new file mode 100644 index 00000000..ad5df93a --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift @@ -0,0 +1,51 @@ +// +// CelestraConfiguration.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Root configuration for Celestra application +public struct CelestraConfiguration: Sendable { + /// CloudKit service configuration + public var cloudkit: CloudKitConfiguration + + /// Update command configuration + public var update: UpdateCommandConfiguration + + /// Initialize Celestra configuration + /// - Parameters: + /// - cloudkit: CloudKit service configuration + /// - update: Update command configuration + public init( + cloudkit: CloudKitConfiguration = .init(), + update: UpdateCommandConfiguration = .init() + ) { + self.cloudkit = cloudkit + self.update = update + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift new file mode 100644 index 00000000..8ce207d6 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift @@ -0,0 +1,95 @@ +// +// CloudKitConfiguration.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit + +/// CloudKit credentials and environment settings +public struct CloudKitConfiguration: Sendable { + /// Default CloudKit container identifier for Celestra + public static let defaultContainerID = "iCloud.com.brightdigit.Celestra" + + /// CloudKit container identifier (e.g., iCloud.com.example.App) + public var containerID: String? + + /// Server-to-Server authentication key ID from Apple Developer Console + public var keyID: String? + + /// Absolute path to PEM-encoded private key file + public var privateKeyPath: String? + + /// CloudKit environment (development or production, default: development) + public var environment: MistKit.Environment + + /// Initialize CloudKit configuration + /// - Parameters: + /// - containerID: CloudKit container identifier + /// - keyID: Server-to-Server authentication key ID + /// - privateKeyPath: Absolute path to PEM-encoded private key file + /// - environment: CloudKit environment + public init( + containerID: String? = nil, + keyID: String? = nil, + privateKeyPath: String? = nil, + environment: MistKit.Environment = .development + ) { + self.containerID = containerID + self.keyID = keyID + self.privateKeyPath = privateKeyPath + self.environment = environment + } + + /// Validate that all required fields are present + public func validated() throws -> ValidatedCloudKitConfiguration { + guard let containerID = containerID, !containerID.isEmpty else { + throw EnhancedConfigurationError( + "CloudKit container ID must be non-empty", + key: "cloudkit.container_id" + ) + } + guard let keyID = keyID, !keyID.isEmpty else { + throw EnhancedConfigurationError( + "CloudKit key ID must be non-empty", + key: "cloudkit.key_id" + ) + } + guard let privateKeyPath = privateKeyPath, !privateKeyPath.isEmpty else { + throw EnhancedConfigurationError( + "CloudKit private key path must be non-empty", + key: "cloudkit.private_key_path" + ) + } + return ValidatedCloudKitConfiguration( + containerID: containerID, + keyID: keyID, + privateKeyPath: privateKeyPath, + environment: environment + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigSource.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigSource.swift new file mode 100644 index 00000000..be27e6eb --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigSource.swift @@ -0,0 +1,38 @@ +// +// ConfigSource.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Configuration source type for error reporting +public enum ConfigSource: String, Sendable { + case cli = "CLI argument" + case environment = "Environment variable" + case file = "Config file" + case defaults = "Default value" +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift new file mode 100644 index 00000000..295104c6 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift @@ -0,0 +1,59 @@ +// +// ConfigurationKeys.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Configuration keys for reading from providers +internal enum ConfigurationKeys { + internal enum CloudKit { + internal static let containerID = "cloudkit.container_id" + internal static let containerIDEnv = "CLOUDKIT_CONTAINER_ID" + internal static let keyID = "cloudkit.key_id" + internal static let keyIDEnv = "CLOUDKIT_KEY_ID" + internal static let privateKeyPath = "cloudkit.private_key_path" + internal static let privateKeyPathEnv = "CLOUDKIT_PRIVATE_KEY_PATH" + internal static let environment = "cloudkit.environment" + internal static let environmentEnv = "CLOUDKIT_ENVIRONMENT" + } + + internal enum Update { + internal static let delay = "update.delay" + internal static let delayEnv = "UPDATE_DELAY" + internal static let skipRobotsCheck = "update.skip_robots_check" + internal static let skipRobotsCheckEnv = "UPDATE_SKIP_ROBOTS_CHECK" + internal static let maxFailures = "update.max_failures" + internal static let maxFailuresEnv = "UPDATE_MAX_FAILURES" + internal static let minPopularity = "update.min_popularity" + internal static let minPopularityEnv = "UPDATE_MIN_POPULARITY" + internal static let lastAttemptedBefore = "update.last_attempted_before" + internal static let lastAttemptedBeforeEnv = "UPDATE_LAST_ATTEMPTED_BEFORE" + internal static let limit = "update.limit" + internal static let limitEnv = "UPDATE_LIMIT" + internal static let jsonOutputPath = "update.json-output-path" + internal static let jsonOutputPathEnv = "UPDATE_JSON_OUTPUT_PATH" + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift new file mode 100644 index 00000000..66f304ba --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift @@ -0,0 +1,149 @@ +// +// ConfigurationLoader.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Configuration +internal import Foundation +internal import MistKit + +/// Loads and merges configuration from multiple sources +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +public actor ConfigurationLoader { + private let configReader: ConfigReader + + /// Creates a new configuration loader with default providers. + public init() { + var providers: [any ConfigProvider] = [] + + // Priority 1: Command-line arguments (highest) + providers.append( + CommandLineArgumentsProvider( + secretsSpecifier: .specific( + [ + "--cloudkit-key-id", + "--cloudkit-private-key-path", + ] + ) + ) + ) + + // Priority 2: Environment variables + providers.append(EnvironmentVariablesProvider()) + + self.configReader = ConfigReader(providers: providers) + } + + /// Load complete configuration with all defaults applied + public func loadConfiguration() async throws -> CelestraConfiguration { + // CloudKit configuration + let cloudkit = CloudKitConfiguration( + containerID: readString(forKey: ConfigurationKeys.CloudKit.containerID) + ?? readString(forKey: ConfigurationKeys.CloudKit.containerIDEnv) + ?? CloudKitConfiguration.defaultContainerID, + keyID: readString(forKey: ConfigurationKeys.CloudKit.keyID) + ?? readString(forKey: ConfigurationKeys.CloudKit.keyIDEnv), + privateKeyPath: readString(forKey: ConfigurationKeys.CloudKit.privateKeyPath) + ?? readString(forKey: ConfigurationKeys.CloudKit.privateKeyPathEnv), + environment: parseEnvironment( + readString(forKey: ConfigurationKeys.CloudKit.environment) + ?? readString(forKey: ConfigurationKeys.CloudKit.environmentEnv) + ) + ) + + // Update command configuration + let delay = + readDouble(forKey: ConfigurationKeys.Update.delay) + ?? readDouble(forKey: ConfigurationKeys.Update.delayEnv) + ?? 2.0 + let skipRobotsCheck = + readBool(forKey: ConfigurationKeys.Update.skipRobotsCheck) + ?? readBool(forKey: ConfigurationKeys.Update.skipRobotsCheckEnv) + ?? false + let maxFailures = + readInt(forKey: ConfigurationKeys.Update.maxFailures) + ?? readInt(forKey: ConfigurationKeys.Update.maxFailuresEnv) + let minPopularity = + readInt(forKey: ConfigurationKeys.Update.minPopularity) + ?? readInt(forKey: ConfigurationKeys.Update.minPopularityEnv) + let lastAttemptedBefore = + readDate(forKey: ConfigurationKeys.Update.lastAttemptedBefore) + ?? readDate(forKey: ConfigurationKeys.Update.lastAttemptedBeforeEnv) + let limit = + readInt(forKey: ConfigurationKeys.Update.limit) + ?? readInt(forKey: ConfigurationKeys.Update.limitEnv) + let jsonOutputPath = + readString(forKey: ConfigurationKeys.Update.jsonOutputPath) + ?? readString(forKey: ConfigurationKeys.Update.jsonOutputPathEnv) + + let update = UpdateCommandConfiguration( + delay: delay, + skipRobotsCheck: skipRobotsCheck, + maxFailures: maxFailures, + minPopularity: minPopularity, + lastAttemptedBefore: lastAttemptedBefore, + limit: limit, + jsonOutputPath: jsonOutputPath + ) + + return CelestraConfiguration( + cloudkit: cloudkit, + update: update + ) + } + + // MARK: - Private Helpers + + private func readString(forKey key: String) -> String? { + configReader.string(forKey: ConfigKey(key)) + } + + private func readDouble(forKey key: String) -> Double? { + configReader.double(forKey: ConfigKey(key)) + } + + // swiftlint:disable:next discouraged_optional_boolean + private func readBool(forKey key: String) -> Bool? { + configReader.bool(forKey: ConfigKey(key)) + } + + private func readInt(forKey key: String) -> Int? { + configReader.int(forKey: ConfigKey(key)) + } + + private func parseEnvironment(_ value: String?) -> MistKit.Environment { + guard let value = value?.lowercased() else { + return .development + } + return value == "production" ? .production : .development + } + + private func readDate(forKey key: String) -> Date? { + // Swift Configuration automatically converts ISO8601 strings to Date + configReader.string(forKey: ConfigKey(key), as: Date.self) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift new file mode 100644 index 00000000..fcf26a54 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift @@ -0,0 +1,66 @@ +// +// EnhancedConfigurationError.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Enhanced configuration error with detailed context +public struct EnhancedConfigurationError: LocalizedError { + /// The error message describing what went wrong. + public let message: String + + /// The configuration key that caused the error, if applicable. + public let key: String? + + /// The source of the configuration value, if applicable. + public let source: ConfigSource? + + /// A localized description of the error. + public var errorDescription: String? { + var parts = [message] + if let key = key { + parts.append("(key: \(key))") + } + if let source = source { + parts.append("(source: \(source.rawValue))") + } + return parts.joined(separator: " ") + } + + /// Creates a new enhanced configuration error. + /// + /// - Parameters: + /// - message: The error message describing what went wrong. + /// - key: The configuration key that caused the error, if applicable. + /// - source: The source of the configuration value, if applicable. + public init(_ message: String, key: String? = nil, source: ConfigSource? = nil) { + self.message = message + self.key = key + self.source = source + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/UpdateCommandConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/UpdateCommandConfiguration.swift new file mode 100644 index 00000000..f8aa4118 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/UpdateCommandConfiguration.swift @@ -0,0 +1,81 @@ +// +// UpdateCommandConfiguration.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Configuration for the update command +public struct UpdateCommandConfiguration: Sendable { + /// Delay between feed updates in seconds (default: 2.0) + public var delay: Double + + /// Skip robots.txt validation (default: false) + public var skipRobotsCheck: Bool + + /// Maximum failure count threshold for filtering feeds + public var maxFailures: Int? + + /// Minimum subscriber count for filtering feeds + public var minPopularity: Int? + + /// Only update feeds last attempted before this date + public var lastAttemptedBefore: Date? + + /// Maximum number of feeds to query and update + public var limit: Int? + + /// Path to write JSON output report (optional) + public var jsonOutputPath: String? + + /// Initialize update command configuration + /// - Parameters: + /// - delay: Delay between feed updates in seconds + /// - skipRobotsCheck: Skip robots.txt validation + /// - maxFailures: Maximum failure count threshold + /// - minPopularity: Minimum subscriber count + /// - lastAttemptedBefore: Only update feeds attempted before this date + /// - limit: Maximum number of feeds to query and update + /// - jsonOutputPath: Path to write JSON output report + public init( + delay: Double = 2.0, + skipRobotsCheck: Bool = false, + maxFailures: Int? = nil, + minPopularity: Int? = nil, + lastAttemptedBefore: Date? = nil, + limit: Int? = nil, + jsonOutputPath: String? = nil + ) { + self.delay = delay + self.skipRobotsCheck = skipRobotsCheck + self.maxFailures = maxFailures + self.minPopularity = minPopularity + self.lastAttemptedBefore = lastAttemptedBefore + self.limit = limit + self.jsonOutputPath = jsonOutputPath + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift new file mode 100644 index 00000000..3af38354 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift @@ -0,0 +1,64 @@ +// +// ValidatedCloudKitConfiguration.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit + +/// Validated CloudKit configuration with all required fields +public struct ValidatedCloudKitConfiguration: Sendable { + /// CloudKit container identifier (validated non-empty) + public let containerID: String + + /// Server-to-Server authentication key ID (validated non-empty) + public let keyID: String + + /// Absolute path to PEM-encoded private key file (validated non-empty) + public let privateKeyPath: String + + /// CloudKit environment (development or production) + public let environment: MistKit.Environment + + /// Initialize validated CloudKit configuration + /// - Parameters: + /// - containerID: CloudKit container identifier + /// - keyID: Server-to-Server authentication key ID + /// - privateKeyPath: Absolute path to PEM-encoded private key file + /// - environment: CloudKit environment + public init( + containerID: String, + keyID: String, + privateKeyPath: String, + environment: MistKit.Environment + ) { + self.containerID = containerID + self.keyID = keyID + self.privateKeyPath = privateKeyPath + self.environment = environment + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/CloudKitConversionError.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/CloudKitConversionError.swift new file mode 100644 index 00000000..80fed348 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Errors/CloudKitConversionError.swift @@ -0,0 +1,49 @@ +// +// CloudKitConversionError.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors thrown during CloudKit record conversion +public enum CloudKitConversionError: LocalizedError { + case missingRequiredField(fieldName: String, recordType: String) + case invalidFieldType(fieldName: String, expected: String, actual: String) + case invalidFieldValue(fieldName: String, reason: String) + + /// Localized error description + public var errorDescription: String? { + switch self { + case .missingRequiredField(let field, let type): + return "Required field '\(field)' missing in \(type) record" + case .invalidFieldType(let field, let expected, let actual): + return "Invalid type for '\(field)': expected \(expected), got \(actual)" + case .invalidFieldValue(let field, let reason): + return "Invalid value for '\(field)': \(reason)" + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift new file mode 100644 index 00000000..33520116 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Article+MistKit.swift @@ -0,0 +1,156 @@ +// +// Article+MistKit.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation +public import MistKit + +extension Article: CloudKitConvertible { + // MARK: - Initializers + + /// Create Article from MistKit RecordInfo using shared parsing helpers. + /// + /// - Parameter record: The CloudKit RecordInfo containing field data. + /// - Throws: `CloudKitConversionError.missingRequiredField` if required fields are missing. + public init(from record: RecordInfo) throws { + let feedRecordName = try record.requiredString(forKey: "feedRecordName", recordType: "Article") + let guid = try record.requiredString(forKey: "guid", recordType: "Article") + let title = try record.requiredString(forKey: "title", recordType: "Article") + let url = try record.requiredString(forKey: "url", recordType: "Article") + let excerpt = record.optionalString(forKey: "excerpt") + let content = record.optionalString(forKey: "content") + let contentText = record.optionalString(forKey: "contentText") + let author = record.optionalString(forKey: "author") + let imageURL = record.optionalString(forKey: "imageURL") + let language = record.optionalString(forKey: "language") + let publishedDate = record.optionalDate(forKey: "publishedTimestamp") + let fetchedAt = record.date(forKey: "fetchedTimestamp", default: Date()) + let expiresAt = record.optionalDate(forKey: "expiresTimestamp") + let ttlDays = Self.calculateTTLDays(fetchedAt: fetchedAt, expiresAt: expiresAt) + let wordCount = record.optionalInt(forKey: "wordCount") + let estimatedReadingTime = record.optionalInt(forKey: "estimatedReadingTime") + let tags = record.stringArray(forKey: "tags") + + self.init( + recordName: record.recordName, + recordChangeTag: record.recordChangeTag, + feedRecordName: feedRecordName, + guid: guid, + title: title, + excerpt: excerpt, + content: content, + contentText: contentText, + author: author, + url: url, + imageURL: imageURL, + publishedDate: publishedDate, + fetchedAt: fetchedAt, + ttlDays: ttlDays, + wordCount: wordCount, + estimatedReadingTime: estimatedReadingTime, + language: language, + tags: tags + ) + } + + // MARK: - Type Methods + + private static func calculateTTLDays(fetchedAt: Date, expiresAt: Date?) -> Int { + guard let expiresAt = expiresAt else { + return 30 + } + let interval = expiresAt.timeIntervalSince(fetchedAt) + return max(1, Int(interval / (24 * 60 * 60))) + } + + // MARK: - Instance Methods + + /// Convert to CloudKit record fields dictionary using MistKit's FieldValue. + /// + /// - Returns: Dictionary mapping field names to FieldValue instances. + public func toFieldsDict() -> [String: FieldValue] { + var fields: [String: FieldValue] = [ + "feedRecordName": .string(feedRecordName), + "guid": .string(guid), + "title": .string(title), + "url": .string(url), + "fetchedTimestamp": .date(fetchedAt), + "expiresTimestamp": .date(expiresAt), + "contentHash": .string(contentHash), + ] + + addOptionalString(&fields, key: "excerpt", value: excerpt) + addOptionalString(&fields, key: "content", value: content) + addOptionalString(&fields, key: "contentText", value: contentText) + addOptionalString(&fields, key: "author", value: author) + addOptionalString(&fields, key: "imageURL", value: imageURL) + addOptionalString(&fields, key: "language", value: language) + addOptionalDate(&fields, key: "publishedTimestamp", value: publishedDate) + addOptionalInt(&fields, key: "wordCount", value: wordCount) + addOptionalInt(&fields, key: "estimatedReadingTime", value: estimatedReadingTime) + + if !tags.isEmpty { + fields["tags"] = .list(tags.map { .string($0) }) + } + + return fields + } + + // MARK: - Private Helpers + + private func addOptionalString( + _ fields: inout [String: FieldValue], + key: String, + value: String? + ) { + if let value = value { + fields[key] = .string(value) + } + } + + private func addOptionalDate( + _ fields: inout [String: FieldValue], + key: String, + value: Date? + ) { + if let value = value { + fields[key] = .date(value) + } + } + + private func addOptionalInt( + _ fields: inout [String: FieldValue], + key: String, + value: Int? + ) { + if let value = value { + fields[key] = .int64(value) + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift new file mode 100644 index 00000000..26db1a1b --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/Feed+MistKit.swift @@ -0,0 +1,171 @@ +// +// Feed+MistKit.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation +public import MistKit + +extension Feed: CloudKitConvertible { + // swiftlint:disable function_body_length + /// Create Feed from MistKit RecordInfo using shared parsing helpers. + /// + /// - Parameter record: The CloudKit RecordInfo containing field data. + /// - Throws: `CloudKitConversionError.missingRequiredField` if feedURL or title is missing. + public init(from record: RecordInfo) throws { + let feedURL = try record.requiredString(forKey: "feedURL", recordType: "Feed") + let title = try record.requiredString(forKey: "title", recordType: "Feed") + let description = record.optionalString(forKey: "description") + let category = record.optionalString(forKey: "category") + let imageURL = record.optionalString(forKey: "imageURL") + let siteURL = record.optionalString(forKey: "siteURL") + let language = record.optionalString(forKey: "language") + let etag = record.optionalString(forKey: "etag") + let lastModified = record.optionalString(forKey: "lastModified") + let lastFailureReason = record.optionalString(forKey: "lastFailureReason") + let isFeatured = record.bool(forKey: "isFeatured") + let isVerified = record.bool(forKey: "isVerified") + let isActive = record.bool(forKey: "isActive", default: true) + let qualityScore = record.int(forKey: "qualityScore", default: 50) + let subscriberCount = record.int64(forKey: "subscriberCount") + let totalAttempts = record.int64(forKey: "totalAttempts") + let successfulAttempts = record.int64(forKey: "successfulAttempts") + let failureCount = record.int64(forKey: "failureCount") + let addedAt = record.date(forKey: "createdTimestamp", default: Date()) + let lastVerified = record.optionalDate(forKey: "verifiedTimestamp") + let lastAttempted = record.optionalDate(forKey: "attemptedTimestamp") + let updateFrequency = record.optionalDouble(forKey: "updateFrequency") + let minUpdateInterval = record.optionalDouble(forKey: "minUpdateInterval") + let tags = record.stringArray(forKey: "tags") + + self.init( + recordName: record.recordName, + recordChangeTag: record.recordChangeTag, + feedURL: feedURL, + title: title, + description: description, + category: category, + imageURL: imageURL, + siteURL: siteURL, + language: language, + isFeatured: isFeatured, + isVerified: isVerified, + qualityScore: qualityScore, + subscriberCount: subscriberCount, + addedAt: addedAt, + lastVerified: lastVerified, + updateFrequency: updateFrequency, + tags: tags, + totalAttempts: totalAttempts, + successfulAttempts: successfulAttempts, + lastAttempted: lastAttempted, + isActive: isActive, + etag: etag, + lastModified: lastModified, + failureCount: failureCount, + lastFailureReason: lastFailureReason, + minUpdateInterval: minUpdateInterval + ) + } + // swiftlint:enable function_body_length + + /// Convert to CloudKit record fields dictionary using MistKit's FieldValue. + /// + /// - Returns: Dictionary mapping field names to FieldValue instances. + public func toFieldsDict() -> [String: FieldValue] { + var fields: [String: FieldValue] = [ + "feedURL": .string(feedURL), + "title": .string(title), + "isFeatured": .int64(isFeatured ? 1 : 0), + "isVerified": .int64(isVerified ? 1 : 0), + "qualityScore": .int64(qualityScore), + "subscriberCount": .int64(Int(subscriberCount)), + "totalAttempts": .int64(Int(totalAttempts)), + "successfulAttempts": .int64(Int(successfulAttempts)), + "isActive": .int64(isActive ? 1 : 0), + "failureCount": .int64(Int(failureCount)), + ] + + // Optional string fields + addOptionalString(&fields, key: "description", value: description) + addOptionalString(&fields, key: "category", value: category) + addOptionalString(&fields, key: "imageURL", value: imageURL) + addOptionalString(&fields, key: "siteURL", value: siteURL) + addOptionalString(&fields, key: "language", value: language) + addOptionalString(&fields, key: "etag", value: etag) + addOptionalString(&fields, key: "lastModified", value: lastModified) + addOptionalString(&fields, key: "lastFailureReason", value: lastFailureReason) + + // Optional date fields + addOptionalDate(&fields, key: "verifiedTimestamp", value: lastVerified) + addOptionalDate(&fields, key: "attemptedTimestamp", value: lastAttempted) + + // Optional numeric fields + addOptionalDouble(&fields, key: "updateFrequency", value: updateFrequency) + addOptionalDouble(&fields, key: "minUpdateInterval", value: minUpdateInterval) + + // Array fields + if !tags.isEmpty { + fields["tags"] = .list(tags.map { .string($0) }) + } + + return fields + } + + // MARK: - Private Helpers + + private func addOptionalString( + _ fields: inout [String: FieldValue], + key: String, + value: String? + ) { + if let value = value { + fields[key] = .string(value) + } + } + + private func addOptionalDate( + _ fields: inout [String: FieldValue], + key: String, + value: Date? + ) { + if let value = value { + fields[key] = .date(value) + } + } + + private func addOptionalDouble( + _ fields: inout [String: FieldValue], + key: String, + value: Double? + ) { + if let value = value { + fields[key] = .double(value) + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift new file mode 100644 index 00000000..754fd0eb --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Extensions/RecordInfo+Parsing.swift @@ -0,0 +1,170 @@ +// +// RecordInfo+Parsing.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit + +/// Extension providing convenient field parsing methods for CloudKit records. +/// +/// These methods simplify extracting typed values from RecordInfo fields, +/// handling the FieldValue enum pattern matching internally. +extension RecordInfo { + /// Extracts a required string field from the record. + /// + /// - Parameters: + /// - key: The field key to extract. + /// - recordType: The record type name for error messages. + /// - Returns: The string value. + /// - Throws: `CloudKitConversionError.missingRequiredField` if the field + /// is missing or empty. + public func requiredString( + forKey key: String, + recordType: String + ) throws -> String { + guard case .string(let value) = fields[key], !value.isEmpty else { + throw CloudKitConversionError.missingRequiredField( + fieldName: key, + recordType: recordType + ) + } + return value + } + + /// Extracts an optional string field from the record. + /// + /// - Parameter key: The field key to extract. + /// - Returns: The string value, or nil if the field is missing. + public func optionalString(forKey key: String) -> String? { + guard case .string(let value) = fields[key] else { + return nil + } + return value + } + + /// Extracts a boolean field from the record (stored as Int64). + /// + /// - Parameters: + /// - key: The field key to extract. + /// - defaultValue: The default value if the field is missing. + /// - Returns: The boolean value, or the default if the field is missing. + public func bool(forKey key: String, default defaultValue: Bool = false) -> Bool { + guard case .int64(let value) = fields[key] else { + return defaultValue + } + return value != 0 + } + + /// Extracts an Int64 field from the record. + /// + /// - Parameters: + /// - key: The field key to extract. + /// - defaultValue: The default value if the field is missing. + /// - Returns: The Int64 value, or the default if the field is missing. + public func int64(forKey key: String, default defaultValue: Int64 = 0) -> Int64 { + guard case .int64(let value) = fields[key] else { + return defaultValue + } + return Int64(value) + } + + /// Extracts an Int field from the record. + /// + /// - Parameters: + /// - key: The field key to extract. + /// - defaultValue: The default value if the field is missing. + /// - Returns: The Int value, or the default if the field is missing. + public func int(forKey key: String, default defaultValue: Int = 0) -> Int { + guard case .int64(let value) = fields[key] else { + return defaultValue + } + return Int(value) + } + + /// Extracts an optional Date field from the record. + /// + /// - Parameter key: The field key to extract. + /// - Returns: The Date value, or nil if the field is missing. + public func optionalDate(forKey key: String) -> Date? { + guard case .date(let value) = fields[key] else { + return nil + } + return value + } + + /// Extracts a Date field with a default value from the record. + /// + /// - Parameters: + /// - key: The field key to extract. + /// - defaultValue: The default value if the field is missing. + /// - Returns: The Date value, or the default if the field is missing. + public func date(forKey key: String, default defaultValue: Date) -> Date { + guard case .date(let value) = fields[key] else { + return defaultValue + } + return value + } + + /// Extracts an optional Double field from the record. + /// + /// - Parameter key: The field key to extract. + /// - Returns: The Double value, or nil if the field is missing. + public func optionalDouble(forKey key: String) -> Double? { + guard case .double(let value) = fields[key] else { + return nil + } + return value + } + + /// Extracts an optional Int field from the record. + /// + /// - Parameter key: The field key to extract. + /// - Returns: The Int value, or nil if the field is missing. + public func optionalInt(forKey key: String) -> Int? { + guard case .int64(let value) = fields[key] else { + return nil + } + return Int(value) + } + + /// Extracts a string array field from the record. + /// + /// - Parameter key: The field key to extract. + /// - Returns: The array of strings, or an empty array if the field is missing. + public func stringArray(forKey key: String) -> [String] { + guard case .list(let values) = fields[key] else { + return [] + } + return values.compactMap { fieldValue in + guard case .string(let str) = fieldValue else { + return nil + } + return str + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/ArticleSyncResult.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/ArticleSyncResult.swift new file mode 100644 index 00000000..f3385481 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/ArticleSyncResult.swift @@ -0,0 +1,67 @@ +// +// ArticleSyncResult.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Result of article synchronization including creation and update statistics +public struct ArticleSyncResult: Sendable { + /// Result of creating new articles + public let created: BatchOperationResult + + /// Result of updating modified articles + public let updated: BatchOperationResult + + /// Number of successfully created articles + public var newCount: Int { created.successCount } + + /// Number of successfully updated articles + public var modifiedCount: Int { updated.successCount } + + /// Total articles processed (created + updated) + public var totalProcessed: Int { + created.totalProcessed + updated.totalProcessed + } + + /// Total successful operations (created + updated) + public var successCount: Int { + created.successCount + updated.successCount + } + + /// Total failed operations + public var failureCount: Int { + created.failureCount + updated.failureCount + } + + /// Initialize article sync result + /// - Parameters: + /// - created: Creation operation result + /// - updated: Update operation result + public init(created: BatchOperationResult, updated: BatchOperationResult) { + self.created = created + self.updated = updated + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/BatchOperationResult.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/BatchOperationResult.swift new file mode 100644 index 00000000..5c1a982b --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/BatchOperationResult.swift @@ -0,0 +1,97 @@ +// +// BatchOperationResult.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation +public import MistKit + +/// Result of a batch CloudKit operation +public struct BatchOperationResult: Sendable { + /// Successfully created/updated records + public var successfulRecords: [RecordInfo] = [] + + /// Records that failed to process + public var failedRecords: [(article: Article, error: any Error)] = [] + + /// Total number of records processed (success + failure) + public var totalProcessed: Int { + successfulRecords.count + failedRecords.count + } + + /// Number of successful operations + public var successCount: Int { + successfulRecords.count + } + + /// Number of failed operations + public var failureCount: Int { + failedRecords.count + } + + /// Success rate as a percentage (0-100) + public var successRate: Double { + guard totalProcessed > 0 else { + return 0 + } + return Double(successCount) / Double(totalProcessed) * 100 + } + + /// Whether all operations succeeded + public var isFullSuccess: Bool { + failureCount == 0 && successCount > 0 + } + + /// Whether all operations failed + public var isFullFailure: Bool { + successCount == 0 && failureCount > 0 + } + + // MARK: - Initializers + + /// Creates an empty batch operation result. + public init() {} + + // MARK: - Mutation + + /// Append results from another batch operation + public mutating func append(_ other: BatchOperationResult) { + successfulRecords.append(contentsOf: other.successfulRecords) + failedRecords.append(contentsOf: other.failedRecords) + } + + /// Append successful records + public mutating func appendSuccesses(_ records: [RecordInfo]) { + successfulRecords.append(contentsOf: records) + } + + /// Append a failure + public mutating func appendFailure(article: Article, error: any Error) { + failedRecords.append((article, error)) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport.swift new file mode 100644 index 00000000..37dfef2d --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Models/UpdateReport.swift @@ -0,0 +1,170 @@ +// +// UpdateReport.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Comprehensive report of feed update operations for JSON export +public struct UpdateReport: Codable, Sendable { + /// When the update started + public let startTime: Date + + /// When the update completed + public let endTime: Date + + /// Total duration in seconds + public var duration: TimeInterval { + endTime.timeIntervalSince(startTime) + } + + /// Configuration used for this update + public let configuration: UpdateConfiguration + + /// Summary statistics + public let summary: Summary + + /// Detailed per-feed results + public let feeds: [FeedResult] + + /// Summary statistics for the update operation + public struct Summary: Codable, Sendable { + public let totalFeeds: Int + public let successCount: Int + public let errorCount: Int + public let skippedCount: Int + public let notModifiedCount: Int + public let articlesCreated: Int + public let articlesUpdated: Int + + public var successRate: Double { + guard totalFeeds > 0 else { return 0 } + return Double(successCount) / Double(totalFeeds) * 100 + } + + public init( + totalFeeds: Int, + successCount: Int, + errorCount: Int, + skippedCount: Int, + notModifiedCount: Int, + articlesCreated: Int, + articlesUpdated: Int + ) { + self.totalFeeds = totalFeeds + self.successCount = successCount + self.errorCount = errorCount + self.skippedCount = skippedCount + self.notModifiedCount = notModifiedCount + self.articlesCreated = articlesCreated + self.articlesUpdated = articlesUpdated + } + } + + /// Configuration snapshot + public struct UpdateConfiguration: Codable, Sendable { + public let delay: Double + public let skipRobotsCheck: Bool + public let maxFailures: Int? + public let minPopularity: Int? + public let limit: Int? + public let environment: String + + public init( + delay: Double, + skipRobotsCheck: Bool, + maxFailures: Int?, + minPopularity: Int?, + limit: Int?, + environment: String + ) { + self.delay = delay + self.skipRobotsCheck = skipRobotsCheck + self.maxFailures = maxFailures + self.minPopularity = minPopularity + self.limit = limit + self.environment = environment + } + } + + /// Result for a single feed update + public struct FeedResult: Codable, Sendable { + public let feedURL: String + public let recordName: String + public let status: String // "success", "error", "skipped", "notModified" + public let articlesCreated: Int + public let articlesUpdated: Int + public let duration: TimeInterval + public let error: String? + + public init( + feedURL: String, + recordName: String, + status: String, + articlesCreated: Int, + articlesUpdated: Int, + duration: TimeInterval, + error: String? = nil + ) { + self.feedURL = feedURL + self.recordName = recordName + self.status = status + self.articlesCreated = articlesCreated + self.articlesUpdated = articlesUpdated + self.duration = duration + self.error = error + } + } + + public init( + startTime: Date, + endTime: Date, + configuration: UpdateConfiguration, + summary: Summary, + feeds: [FeedResult] + ) { + self.startTime = startTime + self.endTime = endTime + self.configuration = configuration + self.summary = summary + self.feeds = feeds + } +} + +// MARK: - JSON Output + +extension UpdateReport { + /// Write the report to a JSON file + public func writeJSON(to path: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + + let data = try encoder.encode(self) + try data.write(to: URL(fileURLWithPath: path)) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitConvertible.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitConvertible.swift new file mode 100644 index 00000000..02db7e68 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitConvertible.swift @@ -0,0 +1,52 @@ +// +// CloudKitConvertible.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit + +/// Protocol for types that can be converted to/from CloudKit records using MistKit +/// +/// Types conforming to this protocol can be: +/// - Converted to CloudKit field dictionaries for creating/updating records +/// - Initialized from CloudKit RecordInfo for reading records +/// +/// This protocol standardizes the conversion pattern used throughout the codebase +/// and enables generic CloudKit operations. +public protocol CloudKitConvertible { + /// Create an instance from a CloudKit record + /// + /// - Parameter record: The CloudKit RecordInfo containing field data + /// - Throws: CloudKitConversionError if required fields are missing or invalid + init(from record: RecordInfo) throws + + /// Convert the instance to a CloudKit fields dictionary + /// + /// - Returns: Dictionary mapping field names to FieldValue instances + func toFieldsDict() -> [String: FieldValue] +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift new file mode 100644 index 00000000..90c66876 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift @@ -0,0 +1,61 @@ +// +// CloudKitRecordOperating.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import MistKit + +/// Protocol for CloudKit record operations, enabling testability via dependency injection +public protocol CloudKitRecordOperating: Sendable { + /// Query records from CloudKit + /// - Parameters: + /// - recordType: The type of record to query + /// - filters: Optional query filters + /// - sortBy: Optional sort descriptors + /// - limit: Maximum number of records to return (optional) + /// - desiredKeys: Optional list of field keys to fetch + /// - Returns: Array of matching record info + /// - Throws: CloudKitError if the query fails + func queryRecords( + recordType: String, + filters: [QueryFilter]?, + sortBy: [QuerySort]?, + limit: Int?, + desiredKeys: [String]? + ) async throws(CloudKitError) -> [RecordInfo] + + /// Modify records in CloudKit (create, update, delete) + /// - Parameter operations: Array of record operations to perform + /// - Returns: Array of modified record info + /// - Throws: CloudKitError if the modification fails + func modifyRecords(_ operations: [RecordOperation]) async throws(CloudKitError) -> [RecordInfo] +} + +// MARK: - CloudKitService Conformance + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension CloudKitService: CloudKitRecordOperating {} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift new file mode 100644 index 00000000..91790c14 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift @@ -0,0 +1,116 @@ +// +// ArticleCategorizer.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation + +/// Pure function type for categorizing feed items into new vs modified articles +@available(macOS 13.0, *) +public struct ArticleCategorizer: Sendable { + /// Result of article categorization + public struct Result: Sendable, Equatable { + /// New articles (GUID not found in existing) + public let new: [Article] + + /// Modified articles (GUID found, contentHash differs) + public let modified: [Article] + + /// Initialize categorization result + /// - Parameters: + /// - new: New articles not found in existing articles + /// - modified: Modified articles with matching GUID but different content + public init(new: [Article], modified: [Article]) { + self.new = new + self.modified = modified + } + } + + /// Initialize article categorizer + public init() {} + + /// Categorize feed items into new and modified articles + /// - Parameters: + /// - items: RSS feed items to process + /// - existingArticles: Existing articles from CloudKit for duplicate detection + /// - feedRecordName: Feed record name to associate with new articles + /// - Returns: Categorization result with new and modified article arrays + public func categorize( + items: [FeedItem], + existingArticles: [Article], + feedRecordName: String + ) -> Result { + // Build lookup map for efficient GUID matching + let existingMap = Dictionary( + uniqueKeysWithValues: existingArticles.map { ($0.guid, $0) } + ) + + var newArticles: [Article] = [] + var modifiedArticles: [Article] = [] + + for item in items { + let article = Article( + feedRecordName: feedRecordName, + guid: item.guid, + title: item.title, + excerpt: item.description, + content: item.content, + author: item.author, + url: item.link, + publishedDate: item.pubDate + ) + + if let existing = existingMap[article.guid] { + // Article exists - check if content changed + if existing.contentHash != article.contentHash { + // Content changed - preserve CloudKit metadata + modifiedArticles.append( + Article( + recordName: existing.recordName, + recordChangeTag: existing.recordChangeTag, + feedRecordName: article.feedRecordName, + guid: article.guid, + title: article.title, + excerpt: article.excerpt, + content: article.content, + author: article.author, + url: article.url, + publishedDate: article.publishedDate + ) + ) + } + // else: unchanged - skip + } else { + // New article + newArticles.append(article) + } + } + + return Result(new: newArticles, modified: modifiedArticles) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift new file mode 100644 index 00000000..bcd9cf73 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift @@ -0,0 +1,330 @@ +// +// ArticleCloudKitService.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation +import Logging +public import MistKit + +// swiftlint:disable file_length + +// MARK: - CloudKit Batch Size Constants + +/// Maximum number of GUIDs per CloudKit query operation. +/// +/// CloudKit supports up to 200 records per batch, but GUID queries using the IN operator +/// benefit from smaller batches to avoid query complexity limits. 150 GUIDs provides +/// optimal balance between query efficiency and avoiding CloudKit rate limits. +private let guidQueryBatchSize = 150 + +/// Maximum number of articles per CloudKit create/update operation. +/// +/// While CloudKit supports up to 200 records per batch, articles contain full HTML content +/// which creates large payloads. Conservative batching at 10 articles prevents: +/// - Payload size limits (CloudKit max request: ~10MB) +/// - Timeout issues with slow network connections +/// - All-or-nothing failure affecting too many records +/// +/// Non-atomic operations allow partial success within each batch. +private let articleMutationBatchSize = 10 + +/// Service for Article-related CloudKit operations with dependency injection support +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +public struct ArticleCloudKitService: Sendable { + private enum BatchOperation { + case create + case update + } + private let recordOperator: any CloudKitRecordOperating + private let operationBuilder: ArticleOperationBuilder + + /// Creates a new Article CloudKit service with dependency injection. + /// + /// - Parameters: + /// - recordOperator: The CloudKit record operator for performing database operations + /// - operationBuilder: Builder for creating article record operations + /// (defaults to ArticleOperationBuilder()) + public init( + recordOperator: any CloudKitRecordOperating, + operationBuilder: ArticleOperationBuilder = ArticleOperationBuilder() + ) { + self.recordOperator = recordOperator + self.operationBuilder = operationBuilder + } + + // MARK: - Query Operations + /// Queries articles from CloudKit by their GUIDs with optional feed filtering. + /// + /// Automatically batches large queries into optimal groups to stay within CloudKit limits. + /// See `guidQueryBatchSize` constant for batch sizing rationale. + /// Invalid article records are logged and skipped rather than failing the entire query. + /// + /// - Parameters: + /// - guids: Array of article GUIDs to query + /// - feedRecordName: Optional feed record name to filter results to a specific feed + /// - Returns: Array of successfully parsed Article objects + /// - Throws: CloudKitError if the query operation fails + public func queryArticlesByGUIDs( + _ guids: [String], + feedRecordName: String? = nil + ) async throws(CloudKitError) -> [Article] { + guard !guids.isEmpty else { + return [] + } + var allArticles: [Article] = [] + let guidBatches = guids.chunked(into: guidQueryBatchSize) + for batch in guidBatches { + let batchArticles = try await queryArticleBatch(batch, feedRecordName: feedRecordName) + allArticles.append(contentsOf: batchArticles) + } + return allArticles + } + + private func queryArticleBatch( + _ guids: [String], + feedRecordName: String? + ) async throws(CloudKitError) -> [Article] { + // CloudKit Web Services has issues with combining .in() with other filters. + // Current approach: Use .in() ONLY for GUID filtering (single filter, no combinations). + // Feed filtering is done in-memory (line 135-136) to avoid the .in() + filter issue. + // + // Known limitation: Cannot efficiently query by both GUID and feedRecordName in one query. + // This is acceptable because GUID queries are typically small batches (<150 items). + // + // Alternative considered: Multiple single-GUID queries would be significantly slower + // and hit rate limits faster. The in-memory filter is the pragmatic solution. + let filters: [QueryFilter] = [.in("guid", guids.map { FieldValue.string($0) })] + let records = try await recordOperator.queryRecords( + recordType: "Article", + filters: filters, + sortBy: nil, + limit: 200, + desiredKeys: nil + ) + let articles = records.compactMap { record in + do { + return try Article(from: record) + } catch { + CelestraLogger.errors.warning( + "Skipping invalid article record \(record.recordName): \(error)" + ) + return nil + } + } + + // Filter by feedRecordName in-memory if specified + if let feedName = feedRecordName { + return articles.filter { $0.feedRecordName == feedName } + } + return articles + } + + // MARK: - Create Operations + /// Creates new articles in CloudKit with batch processing. + /// + /// Articles are processed in conservative batches to manage payload size and prevent timeouts. + /// See `articleMutationBatchSize` constant for batch sizing rationale. + /// Non-atomic operations allow partial success - some articles may succeed while others fail. + /// All successes and failures are tracked in the returned BatchOperationResult. + /// + /// - Parameter articles: Array of Article objects to create in CloudKit + /// - Returns: BatchOperationResult containing success/failure counts and detailed tracking + /// - Throws: CloudKitError if a batch operation fails + public func createArticles(_ articles: [Article]) async throws(CloudKitError) + -> BatchOperationResult + { + guard !articles.isEmpty else { + return BatchOperationResult() + } + CelestraLogger.cloudkit.info("Creating \(articles.count) article(s)...") + let articleBatches = articles.chunked(into: articleMutationBatchSize) + var result = BatchOperationResult() + for (index, batch) in articleBatches.enumerated() { + try await processBatch( + batch, + index: index, + total: articleBatches.count, + result: &result, + operation: .create + ) + } + let rate = String(format: "%.1f", result.successRate) + CelestraLogger.cloudkit.info( + "Batch complete: \(result.successCount)/\(result.totalProcessed) (\(rate)%)" + ) + return result + } + + // MARK: - Update Operations + /// Updates existing articles in CloudKit with batch processing. + /// + /// Articles without a recordName are automatically skipped with a warning. + /// Remaining articles are processed in conservative batches to manage payload size and prevent timeouts. + /// See `articleMutationBatchSize` constant for batch sizing rationale. + /// Non-atomic operations allow partial success - some updates may succeed while others fail. + /// + /// - Parameter articles: Array of Article objects to update (must have recordName set) + /// - Returns: BatchOperationResult containing success/failure counts and detailed tracking + /// - Throws: CloudKitError if a batch operation fails + public func updateArticles(_ articles: [Article]) async throws(CloudKitError) + -> BatchOperationResult + { + guard !articles.isEmpty else { + return BatchOperationResult() + } + CelestraLogger.cloudkit.info("Updating \(articles.count) article(s)...") + let validArticles = articles.filter { $0.recordName != nil } + if validArticles.count != articles.count { + CelestraLogger.errors.warning( + "Skipping \(articles.count - validArticles.count) article(s) without recordName" + ) + } + guard !validArticles.isEmpty else { + return BatchOperationResult() + } + let batches = validArticles.chunked(into: articleMutationBatchSize) + var result = BatchOperationResult() + for (index, batch) in batches.enumerated() { + try await processBatch( + batch, + index: index, + total: batches.count, + result: &result, + operation: .update + ) + } + let updateRateFormatted = String(format: "%.1f", result.successRate) + let updateSummary = "\(result.successCount)/\(result.totalProcessed) succeeded" + CelestraLogger.cloudkit.info("Update complete: \(updateSummary) (\(updateRateFormatted)%)") + return result + } + + /// Processes a single batch of articles with comprehensive error tracking. + /// + /// ## Batch Failure Behavior + /// + /// When `modifyRecords` fails, all articles in the batch are marked as failed with the + /// same error. This is a conservative approach that simplifies error handling. + /// + /// CloudKit batch operations can fail completely (network errors, authentication issues, + /// rate limits) or partially (some records succeed, others fail). The current implementation + /// treats all batch failures as complete failures. + /// + /// ## Future Enhancement Opportunity + /// + /// CloudKit's error responses can contain per-record errors via `CKErrorPartialFailure`. + /// Parsing these would enable finer-grained failure tracking for partial successes. + /// + /// ## Impact on Users + /// + /// - Failed batches are logged with batch number for debugging + /// - `BatchOperationResult` provides success rate and detailed failure list + /// - Users can retry failed articles by filtering `result.failedRecords` + /// + /// - Parameters: + /// - batch: Articles to process in this batch + /// - index: Zero-based batch index for logging + /// - total: Total number of batches for progress reporting + /// - result: Mutable result accumulator for success/failure tracking + /// - operation: Type of operation (create or update) + /// - Throws: CloudKitError if the batch operation fails + private func processBatch( + _ batch: [Article], + index: Int, + total: Int, + result: inout BatchOperationResult, + operation: BatchOperation + ) async throws(CloudKitError) { + CelestraLogger.operations.info( + " Batch \(index + 1)/\(total): \(batch.count) article(s)" + ) + do { + let operations: [RecordOperation] = + switch operation { + case .create: + operationBuilder.buildCreateOperations(batch) + case .update: + operationBuilder.buildUpdateOperations(batch).operations + } + let recordInfos = try await recordOperator.modifyRecords(operations) + result.appendSuccesses(recordInfos) + let verb = operation == .create ? "created" : "updated" + CelestraLogger.cloudkit.info( + " Batch \(index + 1) complete: \(recordInfos.count) \(verb)" + ) + } catch { + // Batch-level failure: All articles marked as failed (conservative approach) + // CloudKit partial failures could allow some successes - see method documentation + CelestraLogger.errors.error(" Batch \(index + 1) failed: \(error.localizedDescription)") + for article in batch { + result.appendFailure(article: article, error: error) + } + } + } + + // MARK: - Delete Operations + /// Deletes all articles from CloudKit in batches. + /// + /// Queries and deletes articles in batches of 200 until no more articles remain. + /// This prevents CloudKit query limits and manages memory usage for large datasets. + /// Progress is logged after each batch deletion. + /// + /// - Throws: CloudKitError if a query or delete operation fails + public func deleteAllArticles() async throws(CloudKitError) { + var totalDeleted = 0 + while true { + let deletedCount = try await deleteArticleBatch() + guard deletedCount > 0 else { + break + } + totalDeleted += deletedCount + CelestraLogger.operations.info("Deleted \(deletedCount) articles (total: \(totalDeleted))") + if deletedCount < 200 { + break + } + } + CelestraLogger.cloudkit.info("Deleted \(totalDeleted) total articles") + } + + private func deleteArticleBatch() async throws(CloudKitError) -> Int { + let articles = try await recordOperator.queryRecords( + recordType: "Article", + filters: nil, + sortBy: nil, + limit: 200, + desiredKeys: ["___recordID"] + ) + guard !articles.isEmpty else { + return 0 + } + let operations = operationBuilder.buildDeleteOperations(articles) + _ = try await recordOperator.modifyRecords(operations) + return articles.count + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleOperationBuilder.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleOperationBuilder.swift new file mode 100644 index 00000000..59088452 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleOperationBuilder.swift @@ -0,0 +1,93 @@ +// +// ArticleOperationBuilder.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation +public import MistKit + +/// Pure function type for building CloudKit record operations from articles. +/// Follows the pattern of ArticleCategorizer and FeedMetadataBuilder for testable, +/// dependency-free operation building. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +public struct ArticleOperationBuilder: Sendable { + /// Initialize article operation builder + public init() {} + + /// Build create operations from articles + /// - Parameter articles: Articles to create (recordName will be generated) + /// - Returns: Array of create operations, one per article + public func buildCreateOperations(_ articles: [Article]) -> [RecordOperation] { + articles.map { article in + RecordOperation.create( + recordType: "Article", + recordName: UUID().uuidString, + fields: article.toFieldsDict() + ) + } + } + + /// Build update operations from articles + /// - Parameter articles: Articles to update (must have recordName) + /// - Returns: Tuple of (operations, skipped count) + /// - operations: Update operations for valid articles + /// - skipped: Count of articles without recordName + public func buildUpdateOperations(_ articles: [Article]) + -> (operations: [RecordOperation], skipped: Int) + { + var skipped = 0 + let operations = articles.compactMap { article -> RecordOperation? in + guard let recordName = article.recordName else { + skipped += 1 + return nil + } + + return RecordOperation.update( + recordType: "Article", + recordName: recordName, + fields: article.toFieldsDict(), + recordChangeTag: article.recordChangeTag + ) + } + + return (operations, skipped) + } + + /// Build delete operations from record info + /// - Parameter records: Record info from query results + /// - Returns: Array of delete operations + public func buildDeleteOperations(_ records: [RecordInfo]) -> [RecordOperation] { + records.map { record in + RecordOperation.delete( + recordType: "Article", + recordName: record.recordName, + recordChangeTag: record.recordChangeTag + ) + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleSyncService.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleSyncService.swift new file mode 100644 index 00000000..0c0085c8 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleSyncService.swift @@ -0,0 +1,107 @@ +// +// ArticleSyncService.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import MistKit + +/// Service for synchronizing articles: query existing, categorize, create/update +@available(macOS 13.0, *) +public struct ArticleSyncService: Sendable { + private let articleService: ArticleCloudKitService + private let categorizer: ArticleCategorizer + + /// Initialize article sync service + /// - Parameters: + /// - articleService: Service for CloudKit article operations + /// - categorizer: Pure function for categorizing articles + public init( + articleService: ArticleCloudKitService, + categorizer: ArticleCategorizer = ArticleCategorizer() + ) { + self.articleService = articleService + self.categorizer = categorizer + } + + /// Synchronize articles with CloudKit using GUID-based deduplication. + /// + /// ## Deduplication Strategy + /// + /// This method prevents duplicate articles through a sequential 4-step process: + /// 1. **Query existing**: Fetch all articles with matching GUIDs from CloudKit + /// 2. **Categorize**: Pure function separates new vs modified articles + /// 3. **Create new**: Upload articles not found in CloudKit + /// 4. **Update modified**: Update articles with changed content (contentHash comparison) + /// + /// GUID-based querying happens *before* any mutations, ensuring duplicate detection + /// is safe even when multiple feed updates run concurrently. Each feed's articles + /// use unique GUIDs scoped to that feed. + /// + /// - Parameters: + /// - items: Fetched RSS feed items to process + /// - feedRecordName: Feed record identifier for scoping queries + /// - Returns: Sync result with creation and update statistics + /// - Throws: CloudKitError if queries or modifications fail + public func syncArticles( + items: [FeedItem], + feedRecordName: String + ) async throws(CloudKitError) -> ArticleSyncResult { + // 1. Query existing articles by GUID + // TEMPORARY: Skip GUID query due to CloudKit Web Services .in() operator issue + // TODO: Fix query or implement alternative deduplication strategy + let existingArticles: [Article] = [] + // let guids = items.map(\.guid) + // let existingArticles = try await articleService.queryArticlesByGUIDs( + // guids, + // feedRecordName: feedRecordName + // ) + + // 2. Categorize into new vs modified (pure function) + let categorization = categorizer.categorize( + items: items, + existingArticles: existingArticles, + feedRecordName: feedRecordName + ) + + // 3. Create new articles + let createResult = try await articleService.createArticles( + categorization.new + ) + + // 4. Update modified articles + let updateResult = try await articleService.updateArticles( + categorization.modified + ) + + // 5. Return aggregated result + return ArticleSyncResult( + created: createResult, + updated: updateResult + ) + } +} diff --git a/Examples/Celestra/Sources/Celestra/Services/CelestraError.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift similarity index 56% rename from Examples/Celestra/Sources/Celestra/Services/CelestraError.swift rename to Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift index 754129ec..0e61d6dc 100644 --- a/Examples/Celestra/Sources/Celestra/Services/CelestraError.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift @@ -1,19 +1,48 @@ -import Foundation -import MistKit +// +// CelestraError.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation +public import MistKit /// Comprehensive error types for Celestra RSS operations -enum CelestraError: LocalizedError { +public enum CelestraError: LocalizedError { /// CloudKit operation failed case cloudKitError(CloudKitError) /// RSS feed fetch failed - case rssFetchFailed(URL, underlying: Error) + case rssFetchFailed(URL, underlying: any Error) /// Invalid feed data received case invalidFeedData(String) /// Batch operation failed - case batchOperationFailed([Error]) + case batchOperationFailed([any Error]) /// CloudKit quota exceeded case quotaExceeded @@ -27,24 +56,31 @@ enum CelestraError: LocalizedError { /// Record not found case recordNotFound(String) + /// CloudKit operation failed with message + case cloudKitOperationFailed(String) + + /// Invalid record name + case invalidRecordName(String) + // MARK: - Retriability /// Determines if this error can be retried - var isRetriable: Bool { + public var isRetriable: Bool { switch self { case .cloudKitError(let ckError): return isCloudKitErrorRetriable(ckError) case .rssFetchFailed, .networkUnavailable: return true case .quotaExceeded, .invalidFeedData, .batchOperationFailed, - .permissionDenied, .recordNotFound: + .permissionDenied, .recordNotFound, .cloudKitOperationFailed, .invalidRecordName: return false } } // MARK: - LocalizedError Conformance - var errorDescription: String? { + /// Localized error description + public var errorDescription: String? { switch self { case .cloudKitError(let error): return "CloudKit operation failed: \(error.localizedDescription)" @@ -62,10 +98,15 @@ enum CelestraError: LocalizedError { return "Permission denied for CloudKit operation." case .recordNotFound(let recordName): return "Record not found: \(recordName)" + case .cloudKitOperationFailed(let message): + return "CloudKit operation failed: \(message)" + case .invalidRecordName(let message): + return "Invalid record name: \(message)" } } - var recoverySuggestion: String? { + /// Suggested recovery action for the error + public var recoverySuggestion: String? { switch self { case .quotaExceeded: return "Wait a few minutes for CloudKit quota to reset, then try again." @@ -77,7 +118,8 @@ enum CelestraError: LocalizedError { return "Check your CloudKit permissions and API token configuration." case .invalidFeedData: return "Verify the feed URL returns valid RSS/Atom data." - case .cloudKitError, .batchOperationFailed, .recordNotFound: + case .cloudKitError, .batchOperationFailed, .recordNotFound, + .cloudKitOperationFailed, .invalidRecordName: return nil } } @@ -88,8 +130,8 @@ enum CelestraError: LocalizedError { private func isCloudKitErrorRetriable(_ error: CloudKitError) -> Bool { switch error { case .httpError(let statusCode), - .httpErrorWithDetails(let statusCode, _, _), - .httpErrorWithRawResponse(let statusCode, _): + .httpErrorWithDetails(let statusCode, _, _), + .httpErrorWithRawResponse(let statusCode, _): // Retry on server errors (5xx) and rate limiting (429) // Don't retry on client errors (4xx) except 429 return statusCode >= 500 || statusCode == 429 diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraLogger.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraLogger.swift new file mode 100644 index 00000000..4696a104 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraLogger.swift @@ -0,0 +1,42 @@ +// +// CelestraLogger.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Logging + +/// Extended logging infrastructure for CelestraCloud +/// +/// Note: RSS and errors loggers are provided by CelestraKit +extension CelestraLogger { + /// Logger for CloudKit operations + public static let cloudkit = Logger(label: "com.brightdigit.Celestra.cloudkit") + + /// Logger for batch and async operations + public static let operations = Logger(label: "com.brightdigit.Celestra.operations") +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift new file mode 100644 index 00000000..4f3f4aa4 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift @@ -0,0 +1,145 @@ +// +// CloudKitService+Celestra.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation +public import Logging +public import MistKit + +/// CloudKit service extensions for Celestra operations +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension CloudKitService { + // MARK: - Feed Operations + + /// Create a new Feed record + public func createFeed(_ feed: Feed) async throws -> RecordInfo { + CelestraLogger.cloudkit.info("📝 Creating feed: \(feed.feedURL)") + + let operation = RecordOperation.create( + recordType: "Feed", + recordName: UUID().uuidString, + fields: feed.toFieldsDict() + ) + let results = try await self.modifyRecords([operation]) + guard let record = results.first else { + throw CloudKitError.invalidResponse + } + return record + } + + /// Update an existing Feed record + public func updateFeed(recordName: String, feed: Feed) async throws -> RecordInfo { + CelestraLogger.cloudkit.info("🔄 Updating feed: \(feed.feedURL)") + + let operation = RecordOperation.update( + recordType: "Feed", + recordName: recordName, + fields: feed.toFieldsDict(), + recordChangeTag: feed.recordChangeTag + ) + let results = try await self.modifyRecords([operation]) + guard let record = results.first else { + throw CloudKitError.invalidResponse + } + return record + } + + /// Query feeds with optional filters (demonstrates QueryFilter and QuerySort) + public func queryFeeds( + lastAttemptedBefore: Date? = nil, + minPopularity: Int? = nil, + limit: Int = 100 + ) async throws -> [Feed] { + var filters: [QueryFilter] = [] + + // Filter by last attempted date if provided + if let cutoff = lastAttemptedBefore { + filters.append(.lessThan("attemptedTimestamp", .date(cutoff))) + } + + // Filter by minimum popularity if provided + if let minPop = minPopularity { + filters.append(.greaterThanOrEquals("subscriberCount", .int64(minPop))) + } + + // Query with filters and sort by feedURL (always queryable+sortable) + let records = try await queryRecords( + recordType: "Feed", + filters: filters.isEmpty ? nil : filters, + sortBy: [.ascending("feedURL")], // Use feedURL since usageCount might have issues + limit: limit + ) + + do { + return try records.map { try Feed(from: $0) } + } catch { + CelestraLogger.errors.error("Failed to convert Feed records: \(error)") + throw error + } + } + + // MARK: - Cleanup Operations + + /// Delete all Feed records (paginated) + public func deleteAllFeeds() async throws { + var totalDeleted = 0 + + while true { + let feeds = try await queryRecords( + recordType: "Feed", + limit: 200, + desiredKeys: ["___recordID"] + ) + + guard !feeds.isEmpty else { + break // No more feeds to delete + } + + let operations = feeds.map { record in + RecordOperation.delete( + recordType: "Feed", + recordName: record.recordName, + recordChangeTag: record.recordChangeTag + ) + } + + _ = try await modifyRecords(operations) + totalDeleted += feeds.count + + CelestraLogger.operations.info("Deleted \(feeds.count) feeds (total: \(totalDeleted))") + + // If we got fewer than the limit, we're done + if feeds.count < 200 { + break + } + } + + CelestraLogger.cloudkit.info("✅ Deleted \(totalDeleted) total feeds") + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedCloudKitService.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedCloudKitService.swift new file mode 100644 index 00000000..d2fbe7ec --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedCloudKitService.swift @@ -0,0 +1,165 @@ +// +// FeedCloudKitService.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation +import Logging +public import MistKit + +/// Service for Feed-related CloudKit operations with dependency injection support +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +public struct FeedCloudKitService: Sendable { + private let recordOperator: any CloudKitRecordOperating + + /// Initialize with a CloudKit record operator + /// - Parameter recordOperator: The record operator to use for CloudKit operations + public init(recordOperator: any CloudKitRecordOperating) { + self.recordOperator = recordOperator + } + + // MARK: - Feed Operations + + /// Create a new Feed record + /// - Parameter feed: The feed to create + /// - Returns: The created record info + /// - Throws: CloudKitError if the operation fails + public func createFeed(_ feed: Feed) async throws(CloudKitError) -> RecordInfo { + CelestraLogger.cloudkit.info("Creating feed: \(feed.feedURL)") + + let operation = RecordOperation.create( + recordType: "Feed", + recordName: UUID().uuidString, + fields: feed.toFieldsDict() + ) + let results = try await recordOperator.modifyRecords([operation]) + guard let record = results.first else { + throw CloudKitError.invalidResponse + } + return record + } + + /// Update an existing Feed record + /// - Parameters: + /// - recordName: The record name to update + /// - feed: The feed data to update + /// - Returns: The updated record info + /// - Throws: CloudKitError if the operation fails + public func updateFeed(recordName: String, feed: Feed) async throws(CloudKitError) -> RecordInfo { + CelestraLogger.cloudkit.info("Updating feed: \(feed.feedURL)") + + let operation = RecordOperation.update( + recordType: "Feed", + recordName: recordName, + fields: feed.toFieldsDict(), + recordChangeTag: feed.recordChangeTag + ) + let results = try await recordOperator.modifyRecords([operation]) + guard let record = results.first else { + throw CloudKitError.invalidResponse + } + return record + } + + /// Query feeds with optional filters + /// - Parameters: + /// - lastAttemptedBefore: Optional date to filter feeds attempted before + /// - minPopularity: Optional minimum subscriber count filter + /// - limit: Maximum number of feeds to return (default 100) + /// - Returns: Array of Feed objects + /// - Throws: CloudKitError if the query fails + public func queryFeeds( + lastAttemptedBefore: Date? = nil, + minPopularity: Int? = nil, + limit: Int = 100 + ) async throws(CloudKitError) -> [Feed] { + var filters: [QueryFilter] = [] + + if let cutoff = lastAttemptedBefore { + filters.append(.lessThan("attemptedTimestamp", .date(cutoff))) + } + + if let minPop = minPopularity { + filters.append(.greaterThanOrEquals("subscriberCount", .int64(minPop))) + } + + let records = try await recordOperator.queryRecords( + recordType: "Feed", + filters: filters.isEmpty ? nil : filters, + sortBy: [.ascending("feedURL")], + limit: limit, + desiredKeys: nil + ) + + do { + return try records.map { try Feed(from: $0) } + } catch { + CelestraLogger.errors.error("Failed to convert Feed records: \(error)") + throw CloudKitError.invalidResponse + } + } + + /// Delete all Feed records (paginated) + /// - Throws: CloudKitError if the operation fails + public func deleteAllFeeds() async throws(CloudKitError) { + var totalDeleted = 0 + + while true { + let feeds = try await recordOperator.queryRecords( + recordType: "Feed", + filters: nil, + sortBy: nil, + limit: 200, + desiredKeys: ["___recordID"] + ) + + guard !feeds.isEmpty else { + break + } + + let operations = feeds.map { record in + RecordOperation.delete( + recordType: "Feed", + recordName: record.recordName, + recordChangeTag: record.recordChangeTag + ) + } + + _ = try await recordOperator.modifyRecords(operations) + totalDeleted += feeds.count + + CelestraLogger.operations.info("Deleted \(feeds.count) feeds (total: \(totalDeleted))") + + if feeds.count < 200 { + break + } + } + + CelestraLogger.cloudkit.info("Deleted \(totalDeleted) total feeds") + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedMetadataBuilder.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedMetadataBuilder.swift new file mode 100644 index 00000000..78c9f5b7 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedMetadataBuilder.swift @@ -0,0 +1,106 @@ +// +// FeedMetadataBuilder.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import CelestraKit +public import Foundation + +/// Pure function type for building feed metadata updates +public struct FeedMetadataBuilder: Sendable { + /// Initialize feed metadata builder + public init() {} + + /// Build metadata for successful feed fetch + /// - Parameters: + /// - feedData: Newly fetched feed data + /// - response: HTTP fetch response with caching headers + /// - feed: Existing feed record + /// - totalAttempts: New total attempt count (existing + 1) + /// - Returns: Metadata update with new feed data and incremented success count + public func buildSuccessMetadata( + feedData: FeedData, + response: FetchResponse, + feed: Feed, + totalAttempts: Int64 + ) -> FeedMetadataUpdate { + FeedMetadataUpdate( + title: feedData.title, + description: feedData.description, + etag: response.etag, + lastModified: response.lastModified, + minUpdateInterval: feedData.minUpdateInterval, + totalAttempts: totalAttempts, + successfulAttempts: feed.successfulAttempts + 1, + failureCount: 0 // Reset on success + ) + } + + /// Build metadata for 304 Not Modified response + /// - Parameters: + /// - feed: Existing feed record + /// - response: HTTP fetch response (may have updated caching headers) + /// - totalAttempts: New total attempt count (existing + 1) + /// - Returns: Metadata update preserving old data, updating HTTP headers if present + public func buildNotModifiedMetadata( + feed: Feed, + response: FetchResponse, + totalAttempts: Int64 + ) -> FeedMetadataUpdate { + FeedMetadataUpdate( + title: feed.title, + description: feed.description, + etag: response.etag ?? feed.etag, // Update if provided, else keep existing + lastModified: response.lastModified ?? feed.lastModified, // Update if provided + minUpdateInterval: feed.minUpdateInterval, + totalAttempts: totalAttempts, + successfulAttempts: feed.successfulAttempts + 1, // Still counts as success + failureCount: 0 // Reset on success + ) + } + + /// Build metadata for failed feed fetch + /// - Parameters: + /// - feed: Existing feed record + /// - totalAttempts: New total attempt count (existing + 1) + /// - Returns: Metadata update preserving all data, incrementing failure count + public func buildErrorMetadata( + feed: Feed, + totalAttempts: Int64 + ) -> FeedMetadataUpdate { + FeedMetadataUpdate( + title: feed.title, + description: feed.description, + etag: feed.etag, + lastModified: feed.lastModified, + minUpdateInterval: feed.minUpdateInterval, + totalAttempts: totalAttempts, + successfulAttempts: feed.successfulAttempts, // No increment on failure + failureCount: feed.failureCount + 1 + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedMetadataUpdate.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedMetadataUpdate.swift new file mode 100644 index 00000000..462c7a4e --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedMetadataUpdate.swift @@ -0,0 +1,87 @@ +// +// FeedMetadataUpdate.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Metadata for updating a feed record +public struct FeedMetadataUpdate: Sendable, Equatable { + /// Feed title from RSS/Atom data + public let title: String + + /// Feed description from RSS/Atom data + public let description: String? + + /// HTTP ETag header for conditional requests + public let etag: String? + + /// HTTP Last-Modified header for conditional requests + public let lastModified: String? + + /// Minimum interval between updates from feed's TTL + public let minUpdateInterval: TimeInterval? + + /// Total number of update attempts + public let totalAttempts: Int64 + + /// Number of successful update attempts + public let successfulAttempts: Int64 + + /// Number of consecutive failures + public let failureCount: Int64 + + /// Initialize feed metadata update + /// - Parameters: + /// - title: Feed title from RSS/Atom data + /// - description: Feed description from RSS/Atom data + /// - etag: HTTP ETag header for conditional requests + /// - lastModified: HTTP Last-Modified header + /// - minUpdateInterval: Minimum interval between updates + /// - totalAttempts: Total number of update attempts + /// - successfulAttempts: Number of successful attempts + /// - failureCount: Number of consecutive failures + public init( + title: String, + description: String?, + etag: String?, + lastModified: String?, + minUpdateInterval: TimeInterval?, + totalAttempts: Int64, + successfulAttempts: Int64, + failureCount: Int64 + ) { + self.title = title + self.description = description + self.etag = etag + self.lastModified = lastModified + self.minUpdateInterval = minUpdateInterval + self.totalAttempts = totalAttempts + self.successfulAttempts = successfulAttempts + self.failureCount = failureCount + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift new file mode 100644 index 00000000..22e7102d --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift @@ -0,0 +1,182 @@ +// +// CloudKitConfigurationTests.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +@Suite("CloudKitConfiguration Tests") +internal struct CloudKitConfigurationTests { + @Test("Valid configuration with all fields") + internal func testValidConfigurationWithAllFields() throws { + let config = CloudKitConfiguration( + containerID: "iCloud.com.example.Test", + keyID: "TEST_KEY_ID", + privateKeyPath: "/path/to/key.pem", + environment: .production + ) + + let validated = try config.validated() + + #expect(validated.containerID == "iCloud.com.example.Test") + #expect(validated.keyID == "TEST_KEY_ID") + #expect(validated.privateKeyPath == "/path/to/key.pem") + #expect(validated.environment == .production) + } + + @Test("Valid configuration with default environment") + internal func testValidConfigurationWithDefaultEnvironment() throws { + let config = CloudKitConfiguration( + containerID: "iCloud.com.example.Test", + keyID: "TEST_KEY_ID", + privateKeyPath: "/path/to/key.pem" + ) + + let validated = try config.validated() + + #expect(validated.environment == .development) + } + + @Test("Missing containerID throws error") + internal func testMissingContainerIDThrowsError() { + let config = CloudKitConfiguration( + containerID: nil, + keyID: "TEST_KEY_ID", + privateKeyPath: "/path/to/key.pem" + ) + + #expect(throws: EnhancedConfigurationError.self) { + try config.validated() + } + } + + @Test("Empty containerID throws error with updated message") + internal func testEmptyContainerIDThrowsError() { + let config = CloudKitConfiguration( + containerID: "", + keyID: "TEST_KEY_ID", + privateKeyPath: "/path/to/key.pem" + ) + + do { + _ = try config.validated() + Issue.record("Expected error to be thrown for empty containerID") + } catch let error as EnhancedConfigurationError { + #expect(error.message == "CloudKit container ID must be non-empty") + #expect(error.key == "cloudkit.container_id") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Missing keyID throws error") + internal func testMissingKeyIDThrowsError() { + let config = CloudKitConfiguration( + containerID: "iCloud.com.example.Test", + keyID: nil, + privateKeyPath: "/path/to/key.pem" + ) + + #expect(throws: EnhancedConfigurationError.self) { + try config.validated() + } + } + + @Test("Empty keyID throws error with updated message") + internal func testEmptyKeyIDThrowsError() { + let config = CloudKitConfiguration( + containerID: "iCloud.com.example.Test", + keyID: "", + privateKeyPath: "/path/to/key.pem" + ) + + do { + _ = try config.validated() + Issue.record("Expected error to be thrown for empty keyID") + } catch let error as EnhancedConfigurationError { + #expect(error.message == "CloudKit key ID must be non-empty") + #expect(error.key == "cloudkit.key_id") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Missing privateKeyPath throws error") + internal func testMissingPrivateKeyPathThrowsError() { + let config = CloudKitConfiguration( + containerID: "iCloud.com.example.Test", + keyID: "TEST_KEY_ID", + privateKeyPath: nil + ) + + #expect(throws: EnhancedConfigurationError.self) { + try config.validated() + } + } + + @Test("Empty privateKeyPath throws error with updated message") + internal func testEmptyPrivateKeyPathThrowsError() { + let config = CloudKitConfiguration( + containerID: "iCloud.com.example.Test", + keyID: "TEST_KEY_ID", + privateKeyPath: "" + ) + + do { + _ = try config.validated() + Issue.record("Expected error to be thrown for empty privateKeyPath") + } catch let error as EnhancedConfigurationError { + #expect(error.message == "CloudKit private key path must be non-empty") + #expect(error.key == "cloudkit.private_key_path") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Environment set to production") + internal func testEnvironmentSetToProduction() throws { + let config = CloudKitConfiguration( + containerID: "iCloud.com.example.Test", + keyID: "TEST_KEY_ID", + privateKeyPath: "/path/to/key.pem", + environment: .production + ) + + let validated = try config.validated() + + #expect(validated.environment == .production) + } + + @Test("Default container ID constant") + internal func testDefaultContainerIDConstant() { + #expect(CloudKitConfiguration.defaultContainerID == "iCloud.com.brightdigit.Celestra") + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/UpdateCommandConfigurationTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/UpdateCommandConfigurationTests.swift new file mode 100644 index 00000000..33f12b07 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/UpdateCommandConfigurationTests.swift @@ -0,0 +1,185 @@ +// +// UpdateCommandConfigurationTests.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import CelestraCloudKit + +@Suite("UpdateCommandConfiguration Tests") +internal struct UpdateCommandConfigurationTests { + @Test("Default values are applied correctly") + internal func testDefaultValues() { + let config = UpdateCommandConfiguration() + + #expect(config.delay == 2.0) + #expect(config.skipRobotsCheck == false) + #expect(config.maxFailures == nil) + #expect(config.minPopularity == nil) + #expect(config.lastAttemptedBefore == nil) + #expect(config.limit == nil) + } + + @Test("Custom delay value") + internal func testCustomDelay() { + let config = UpdateCommandConfiguration( + delay: 5.0, + skipRobotsCheck: false, + maxFailures: nil, + minPopularity: nil, + lastAttemptedBefore: nil, + limit: nil + ) + + #expect(config.delay == 5.0) + } + + @Test("Skip robots check flag") + internal func testSkipRobotsCheck() { + let config = UpdateCommandConfiguration( + delay: 2.0, + skipRobotsCheck: true, + maxFailures: nil, + minPopularity: nil, + lastAttemptedBefore: nil, + limit: nil + ) + + #expect(config.skipRobotsCheck == true) + } + + @Test("Max failures value") + internal func testMaxFailures() { + let config = UpdateCommandConfiguration( + delay: 2.0, + skipRobotsCheck: false, + maxFailures: 5, + minPopularity: nil, + lastAttemptedBefore: nil, + limit: nil + ) + + #expect(config.maxFailures == 5) + } + + @Test("Min popularity value") + internal func testMinPopularity() { + let config = UpdateCommandConfiguration( + delay: 2.0, + skipRobotsCheck: false, + maxFailures: nil, + minPopularity: 100, + lastAttemptedBefore: nil, + limit: nil + ) + + #expect(config.minPopularity == 100) + } + + @Test("Last attempted before date") + internal func testLastAttemptedBefore() { + let testDate = Date(timeIntervalSince1970: 1_704_067_200) // 2024-01-01T00:00:00Z + let config = UpdateCommandConfiguration( + delay: 2.0, + skipRobotsCheck: false, + maxFailures: nil, + minPopularity: nil, + lastAttemptedBefore: testDate, + limit: nil + ) + + #expect(config.lastAttemptedBefore == testDate) + } + + @Test("Limit value") + internal func testLimit() { + let config = UpdateCommandConfiguration( + delay: 2.0, + skipRobotsCheck: false, + maxFailures: nil, + minPopularity: nil, + lastAttemptedBefore: nil, + limit: 50 + ) + + #expect(config.limit == 50) + } + + @Test("All custom values") + internal func testAllCustomValues() { + let testDate = Date(timeIntervalSince1970: 1_704_067_200) + let config = UpdateCommandConfiguration( + delay: 3.5, + skipRobotsCheck: true, + maxFailures: 10, + minPopularity: 200, + lastAttemptedBefore: testDate, + limit: 100 + ) + + #expect(config.delay == 3.5) + #expect(config.skipRobotsCheck == true) + #expect(config.maxFailures == 10) + #expect(config.minPopularity == 200) + #expect(config.lastAttemptedBefore == testDate) + #expect(config.limit == 100) + } + + @Test("Negative delay value") + internal func testNegativeDelay() { + let config = UpdateCommandConfiguration( + delay: -1.0, + skipRobotsCheck: false, + maxFailures: nil, + minPopularity: nil, + lastAttemptedBefore: nil, + limit: nil + ) + + #expect(config.delay == -1.0) + // Note: Validation should happen at a higher level (command execution) + } + + @Test("Zero values for numeric fields") + internal func testZeroValues() { + let config = UpdateCommandConfiguration( + delay: 0.0, + skipRobotsCheck: false, + maxFailures: 0, + minPopularity: 0, + lastAttemptedBefore: nil, + limit: 0 + ) + + #expect(config.delay == 0.0) + #expect(config.maxFailures == 0) + #expect(config.minPopularity == 0) + #expect(config.limit == 0) + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests.swift new file mode 100644 index 00000000..c5162528 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests.swift @@ -0,0 +1,262 @@ +// +// CelestraErrorTests.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +@Suite("CelestraError Tests") +internal struct CelestraErrorTests { + // MARK: - Retriability Tests + + @Test("Network unavailable is retriable") + internal func testNetworkUnavailableRetriable() { + let error = CelestraError.networkUnavailable + #expect(error.isRetriable == true) + } + + @Test("RSS fetch failed is retriable") + internal func testRSSFetchFailedRetriable() { + let url = URL(string: "https://example.com/feed.xml")! + let underlyingError = NSError(domain: "Test", code: 1) + let error = CelestraError.rssFetchFailed(url, underlying: underlyingError) + + #expect(error.isRetriable == true) + } + + @Test("Quota exceeded is not retriable") + internal func testQuotaExceededNotRetriable() { + let error = CelestraError.quotaExceeded + #expect(error.isRetriable == false) + } + + @Test("Permission denied is not retriable") + internal func testPermissionDeniedNotRetriable() { + let error = CelestraError.permissionDenied + #expect(error.isRetriable == false) + } + + @Test("Invalid feed data is not retriable") + internal func testInvalidFeedDataNotRetriable() { + let error = CelestraError.invalidFeedData("Malformed XML") + #expect(error.isRetriable == false) + } + + @Test("Record not found is not retriable") + internal func testRecordNotFoundNotRetriable() { + let error = CelestraError.recordNotFound("feed-123") + #expect(error.isRetriable == false) + } + + @Test("CloudKit 5xx errors are retriable") + internal func testCloudKit5xxRetriable() { + let ckError = CloudKitError.httpError(statusCode: 500) + let error = CelestraError.cloudKitError(ckError) + + #expect(error.isRetriable == true) + } + + @Test("CloudKit 503 error is retriable") + internal func testCloudKit503Retriable() { + let ckError = CloudKitError.httpError(statusCode: 503) + let error = CelestraError.cloudKitError(ckError) + + #expect(error.isRetriable == true) + } + + @Test("CloudKit 429 rate limit error is retriable") + internal func testCloudKit429Retriable() { + let ckError = CloudKitError.httpError(statusCode: 429) + let error = CelestraError.cloudKitError(ckError) + + #expect(error.isRetriable == true) + } + + @Test("CloudKit 4xx client errors are not retriable") + internal func testCloudKit4xxNotRetriable() { + let ckError = CloudKitError.httpError(statusCode: 400) + let error = CelestraError.cloudKitError(ckError) + + #expect(error.isRetriable == false) + } + + @Test("CloudKit 404 error is not retriable") + internal func testCloudKit404NotRetriable() { + let ckError = CloudKitError.httpError(statusCode: 404) + let error = CelestraError.cloudKitError(ckError) + + #expect(error.isRetriable == false) + } + + @Test("CloudKit network error is retriable") + internal func testCloudKitNetworkErrorRetriable() { + let urlError = URLError(.networkConnectionLost) + let ckError = CloudKitError.networkError(urlError) + let error = CelestraError.cloudKitError(ckError) + + #expect(error.isRetriable == true) + } + + @Test("CloudKit invalid response is retriable") + internal func testCloudKitInvalidResponseRetriable() { + let ckError = CloudKitError.invalidResponse + let error = CelestraError.cloudKitError(ckError) + + #expect(error.isRetriable == true) + } + + // MARK: - Error Description Tests + + @Test("Quota exceeded has description") + internal func testQuotaExceededDescription() { + let error = CelestraError.quotaExceeded + + #expect(error.errorDescription?.contains("quota") == true) + #expect(error.errorDescription?.contains("exceeded") == true) + } + + @Test("Network unavailable has description") + internal func testNetworkUnavailableDescription() { + let error = CelestraError.networkUnavailable + + #expect(error.errorDescription?.contains("Network") == true) + #expect(error.errorDescription?.contains("unavailable") == true) + } + + @Test("Permission denied has description") + internal func testPermissionDeniedDescription() { + let error = CelestraError.permissionDenied + + #expect(error.errorDescription?.contains("Permission") == true) + #expect(error.errorDescription?.contains("denied") == true) + } + + @Test("Invalid feed data includes reason") + internal func testInvalidFeedDataDescription() { + let error = CelestraError.invalidFeedData("Malformed XML") + + #expect(error.errorDescription?.contains("Invalid feed data") == true) + #expect(error.errorDescription?.contains("Malformed XML") == true) + } + + @Test("Record not found includes record name") + internal func testRecordNotFoundDescription() { + let error = CelestraError.recordNotFound("feed-abc123") + + #expect(error.errorDescription?.contains("Record not found") == true) + #expect(error.errorDescription?.contains("feed-abc123") == true) + } + + @Test("RSS fetch failed includes URL") + internal func testRSSFetchFailedDescription() { + let url = URL(string: "https://example.com/feed.xml")! + let underlyingError = NSError(domain: "Test", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Connection timeout", + ]) + let error = CelestraError.rssFetchFailed(url, underlying: underlyingError) + + #expect(error.errorDescription?.contains("example.com/feed.xml") == true) + #expect(error.errorDescription?.contains("Failed to fetch") == true) + } + + @Test("Batch operation failed includes error count") + internal func testBatchOperationFailedDescription() { + let errors: [any Error] = [ + NSError(domain: "Test", code: 1), + NSError(domain: "Test", code: 2), + NSError(domain: "Test", code: 3), + ] + let error = CelestraError.batchOperationFailed(errors) + + #expect(error.errorDescription?.contains("3") == true) + #expect(error.errorDescription?.contains("Batch operation failed") == true) + } + + // MARK: - Recovery Suggestion Tests + + @Test("Quota exceeded has recovery suggestion") + internal func testQuotaExceededRecoverySuggestion() { + let error = CelestraError.quotaExceeded + + #expect(error.recoverySuggestion != nil) + #expect(error.recoverySuggestion?.contains("Wait") == true) + #expect(error.recoverySuggestion?.contains("quota") == true) + } + + @Test("Network unavailable has recovery suggestion") + internal func testNetworkUnavailableRecoverySuggestion() { + let error = CelestraError.networkUnavailable + + #expect(error.recoverySuggestion != nil) + #expect(error.recoverySuggestion?.contains("connection") == true) + } + + @Test("RSS fetch failed has recovery suggestion") + internal func testRSSFetchFailedRecoverySuggestion() { + let url = URL(string: "https://example.com/feed.xml")! + let underlyingError = NSError(domain: "Test", code: 1) + let error = CelestraError.rssFetchFailed(url, underlying: underlyingError) + + #expect(error.recoverySuggestion != nil) + #expect(error.recoverySuggestion?.contains("feed URL") == true) + } + + @Test("Permission denied has recovery suggestion") + internal func testPermissionDeniedRecoverySuggestion() { + let error = CelestraError.permissionDenied + + #expect(error.recoverySuggestion != nil) + #expect(error.recoverySuggestion?.contains("permissions") == true) + } + + @Test("Invalid feed data has recovery suggestion") + internal func testInvalidFeedDataRecoverySuggestion() { + let error = CelestraError.invalidFeedData("Invalid XML") + + #expect(error.recoverySuggestion != nil) + #expect(error.recoverySuggestion?.contains("RSS") == true) + } + + @Test("Record not found has no recovery suggestion") + internal func testRecordNotFoundNoRecoverySuggestion() { + let error = CelestraError.recordNotFound("feed-123") + + #expect(error.recoverySuggestion == nil) + } + + @Test("CloudKit error has no recovery suggestion") + internal func testCloudKitErrorNoRecoverySuggestion() { + let ckError = CloudKitError.httpError(statusCode: 500) + let error = CelestraError.cloudKitError(ckError) + + #expect(error.recoverySuggestion == nil) + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CloudKitConversionErrorTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CloudKitConversionErrorTests.swift new file mode 100644 index 00000000..4515cbe4 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CloudKitConversionErrorTests.swift @@ -0,0 +1,132 @@ +// +// CloudKitConversionErrorTests.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import CelestraCloudKit + +@Suite("CloudKitConversionError Tests") +internal struct CloudKitConversionErrorTests { + @Test("Missing required field error description") + internal func testMissingRequiredFieldDescription() { + let error = CloudKitConversionError.missingRequiredField( + fieldName: "feedURL", + recordType: "Feed" + ) + + let description = error.errorDescription + #expect(description?.contains("feedURL") == true) + #expect(description?.contains("Feed") == true) + #expect(description?.contains("Required field") == true) + #expect(description?.contains("missing") == true) + } + + @Test("Invalid field type error description") + internal func testInvalidFieldTypeDescription() { + let error = CloudKitConversionError.invalidFieldType( + fieldName: "subscriberCount", + expected: "Int64", + actual: "String" + ) + + let description = error.errorDescription + #expect(description?.contains("subscriberCount") == true) + #expect(description?.contains("Int64") == true) + #expect(description?.contains("String") == true) + #expect(description?.contains("Invalid type") == true) + } + + @Test("Invalid field value error description") + internal func testInvalidFieldValueDescription() { + let error = CloudKitConversionError.invalidFieldValue( + fieldName: "feedURL", + reason: "Not a valid URL" + ) + + let description = error.errorDescription + #expect(description?.contains("feedURL") == true) + #expect(description?.contains("Not a valid URL") == true) + #expect(description?.contains("Invalid value") == true) + } + + @Test("Missing required field with empty string") + internal func testMissingRequiredFieldEmptyString() { + let error = CloudKitConversionError.missingRequiredField( + fieldName: "", + recordType: "Article" + ) + + let description = error.errorDescription + #expect(description != nil) + #expect(description?.contains("Article") == true) + } + + @Test("Error is LocalizedError") + internal func testLocalizedErrorConformance() { + let error: any LocalizedError = CloudKitConversionError.missingRequiredField( + fieldName: "title", + recordType: "Feed" + ) + + #expect(error.errorDescription != nil) + } + + @Test("Different field names produce different descriptions") + internal func testDifferentFieldNames() { + let error1 = CloudKitConversionError.missingRequiredField( + fieldName: "title", + recordType: "Feed" + ) + let error2 = CloudKitConversionError.missingRequiredField( + fieldName: "guid", + recordType: "Feed" + ) + + #expect(error1.errorDescription != error2.errorDescription) + #expect(error1.errorDescription?.contains("title") == true) + #expect(error2.errorDescription?.contains("guid") == true) + } + + @Test("Different record types produce different descriptions") + internal func testDifferentRecordTypes() { + let error1 = CloudKitConversionError.missingRequiredField( + fieldName: "title", + recordType: "Feed" + ) + let error2 = CloudKitConversionError.missingRequiredField( + fieldName: "title", + recordType: "Article" + ) + + #expect(error1.errorDescription != error2.errorDescription) + #expect(error1.errorDescription?.contains("Feed") == true) + #expect(error2.errorDescription?.contains("Article") == true) + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift new file mode 100644 index 00000000..dc8e3ee1 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift @@ -0,0 +1,162 @@ +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension ArticleConversion { + @Suite("Article from CloudKit Conversion") + internal struct FromCloudKit { + @Test("init(from:) parses all fields correctly") + internal func testInitFromRecordAllFields() throws { + let fetchedDate = Date(timeIntervalSince1970: 1_000_000) + let expiresDate = Date(timeIntervalSince1970: 3_000_000) + + let fields: [String: FieldValue] = [ + "feedRecordName": .string("feed-123"), + "guid": .string("guid-456"), + "title": .string("Complete Article"), + "url": .string("https://example.com/complete"), + "publishedTimestamp": .date(Date(timeIntervalSince1970: 500_000)), + "excerpt": .string("Excerpt text"), + "content": .string("

HTML content

"), + "contentText": .string("Plain text"), + "author": .string("Jane Smith"), + "imageURL": .string("https://example.com/img.jpg"), + "language": .string("en-US"), + "tags": .list([.string("news"), .string("tech")]), + "wordCount": .int64(750), + "estimatedReadingTime": .int64(4), + "fetchedTimestamp": .date(fetchedDate), + "expiresTimestamp": .date(expiresDate), + "contentHash": .string("complete-hash"), + ] + + let record = RecordInfo( + recordName: "complete-article-record", + recordType: "Article", + recordChangeTag: "tag-123", + fields: fields + ) + + let article = try Article(from: record) + + #expect(article.recordName == "complete-article-record") + #expect(article.feedRecordName == "feed-123") + #expect(article.guid == "guid-456") + #expect(article.title == "Complete Article") + #expect(article.url == "https://example.com/complete") + #expect(article.publishedDate == Date(timeIntervalSince1970: 500_000)) + #expect(article.excerpt == "Excerpt text") + #expect(article.content == "

HTML content

") + #expect(article.contentText == "Plain text") + #expect(article.author == "Jane Smith") + #expect(article.imageURL == "https://example.com/img.jpg") + #expect(article.language == "en-US") + #expect(article.tags == ["news", "tech"]) + #expect(article.wordCount == 750) + #expect(article.estimatedReadingTime == 4) + #expect(article.fetchedAt == fetchedDate) + } + + @Test("init(from:) handles missing optional fields with defaults") + internal func testInitFromRecordMissingFields() throws { + let fetchedDate = Date(timeIntervalSince1970: 1_000_000) + let expiresDate = Date(timeIntervalSince1970: 2_000_000) + + let fields: [String: FieldValue] = [ + "feedRecordName": .string("feed-123"), + "guid": .string("guid-789"), + "title": .string("Minimal Article"), + "url": .string("https://example.com/minimal"), + "fetchedTimestamp": .date(fetchedDate), + "expiresTimestamp": .date(expiresDate), + "contentHash": .string("hash-minimal"), + ] + + let record = RecordInfo( + recordName: "minimal-article-record", + recordType: "Article", + recordChangeTag: nil, + fields: fields + ) + + let article = try Article(from: record) + + // Required fields should be set + #expect(article.feedRecordName == "feed-123") + #expect(article.guid == "guid-789") + #expect(article.title == "Minimal Article") + #expect(article.url == "https://example.com/minimal") + #expect(article.fetchedAt == fetchedDate) + + // Optional fields should be nil or empty + #expect(article.publishedDate == nil) + #expect(article.excerpt == nil) + #expect(article.content == nil) + #expect(article.contentText == nil) + #expect(article.author == nil) + #expect(article.imageURL == nil) + #expect(article.language == nil) + #expect(article.tags.isEmpty) + #expect(article.wordCount == nil) + #expect(article.estimatedReadingTime == nil) + } + + @Test("Round-trip conversion preserves data") + internal func testRoundTripConversion() throws { + let originalArticle = Article( + recordName: "roundtrip-article", + recordChangeTag: "rt-tag", + feedRecordName: "feed-rt", + guid: "guid-rt-123", + title: "Round Trip Article", + excerpt: "Round trip excerpt", + content: "

Round trip content

", + contentText: "Round trip text", + author: "Round Trip Author", + url: "https://example.com/roundtrip", + imageURL: "https://example.com/rt.jpg", + publishedDate: Date(timeIntervalSince1970: 700_000), + fetchedAt: Date(timeIntervalSince1970: 1_000_000), + ttlDays: 45, + wordCount: 600, + estimatedReadingTime: 3, + language: "en", + tags: ["roundtrip", "test"] + ) + + // Convert to fields + let fields = originalArticle.toFieldsDict() + + // Create a record + let record = RecordInfo( + recordName: originalArticle.recordName ?? "roundtrip-article", + recordType: "Article", + recordChangeTag: originalArticle.recordChangeTag, + fields: fields + ) + + // Convert back to Article + let reconstructedArticle = try Article(from: record) + + // Verify all fields match + #expect(reconstructedArticle.feedRecordName == originalArticle.feedRecordName) + #expect(reconstructedArticle.guid == originalArticle.guid) + #expect(reconstructedArticle.title == originalArticle.title) + #expect(reconstructedArticle.url == originalArticle.url) + #expect(reconstructedArticle.publishedDate == originalArticle.publishedDate) + #expect(reconstructedArticle.excerpt == originalArticle.excerpt) + #expect(reconstructedArticle.content == originalArticle.content) + #expect(reconstructedArticle.contentText == originalArticle.contentText) + #expect(reconstructedArticle.author == originalArticle.author) + #expect(reconstructedArticle.imageURL == originalArticle.imageURL) + #expect(reconstructedArticle.language == originalArticle.language) + #expect(reconstructedArticle.tags == originalArticle.tags) + #expect(reconstructedArticle.wordCount == originalArticle.wordCount) + #expect(reconstructedArticle.estimatedReadingTime == originalArticle.estimatedReadingTime) + #expect(reconstructedArticle.fetchedAt == originalArticle.fetchedAt) + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift new file mode 100644 index 00000000..ef341bef --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift @@ -0,0 +1,109 @@ +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension ArticleConversion { + @Suite("Article to CloudKit Conversion") + internal struct ToCloudKit { + @Test("toFieldsDict converts required fields correctly") + internal func testToFieldsDictRequiredFields() { + let article = Article( + feedRecordName: "feed-123", + guid: "article-guid-456", + title: "Test Article", + url: "https://example.com/article", + fetchedAt: Date(timeIntervalSince1970: 1_000_000), + ttlDays: 30 + ) + + let fields = article.toFieldsDict() + + // Check required fields + #expect(fields["feedRecordName"] == .string("feed-123")) + #expect(fields["guid"] == .string("article-guid-456")) + #expect(fields["title"] == .string("Test Article")) + #expect(fields["url"] == .string("https://example.com/article")) + #expect(fields["fetchedTimestamp"] == .date(Date(timeIntervalSince1970: 1_000_000))) + // expiresTimestamp and contentHash are stored, check they exist + #expect(fields["expiresTimestamp"] != nil) + #expect(fields["contentHash"] != nil) + } + + @Test("toFieldsDict handles optional fields correctly") + internal func testToFieldsDictOptionalFields() { + let article = Article( + feedRecordName: "feed-123", + guid: "article-guid-456", + title: "Full Article", + excerpt: "This is an excerpt", + content: "

Full HTML content

", + contentText: "Full text content", + author: "John Doe", + url: "https://example.com/article", + imageURL: "https://example.com/image.jpg", + publishedDate: Date(timeIntervalSince1970: 500_000), + fetchedAt: Date(timeIntervalSince1970: 1_000_000), + ttlDays: 60, + wordCount: 500, + estimatedReadingTime: 3, + language: "en", + tags: ["tech", "swift"] + ) + + let fields = article.toFieldsDict() + + // Check optional string fields + #expect(fields["excerpt"] == .string("This is an excerpt")) + #expect(fields["content"] == .string("

Full HTML content

")) + #expect(fields["contentText"] == .string("Full text content")) + #expect(fields["author"] == .string("John Doe")) + #expect(fields["imageURL"] == .string("https://example.com/image.jpg")) + #expect(fields["language"] == .string("en")) + + // Check optional date field + #expect(fields["publishedTimestamp"] == .date(Date(timeIntervalSince1970: 500_000))) + + // Check optional numeric fields + #expect(fields["wordCount"] == .int64(500)) + #expect(fields["estimatedReadingTime"] == .int64(3)) + + // Check array field + if case .list(let tagValues) = fields["tags"] { + #expect(tagValues.count == 2) + #expect(tagValues[0] == .string("tech")) + #expect(tagValues[1] == .string("swift")) + } else { + Issue.record("tags field should be a list") + } + } + + @Test("toFieldsDict omits nil optional fields") + internal func testToFieldsDictOmitsNilFields() { + let article = Article( + feedRecordName: "feed-123", + guid: "guid-789", + title: "Minimal Article", + url: "https://example.com/minimal", + fetchedAt: Date(), + ttlDays: 30 + ) + + let fields = article.toFieldsDict() + + // Verify optional fields are not present when nil + #expect(fields["publishedTimestamp"] == nil) + #expect(fields["excerpt"] == nil) + #expect(fields["content"] == nil) + #expect(fields["contentText"] == nil) + #expect(fields["author"] == nil) + #expect(fields["imageURL"] == nil) + #expect(fields["language"] == nil) + #expect(fields["wordCount"] == nil) + #expect(fields["estimatedReadingTime"] == nil) + #expect(fields["tags"] == nil) + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion.swift new file mode 100644 index 00000000..08e4a2d2 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion.swift @@ -0,0 +1,2 @@ +/// Namespace for Article conversion tests +internal enum ArticleConversion {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift new file mode 100644 index 00000000..e5abb96d --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift @@ -0,0 +1,146 @@ +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension FeedConversion { + @Suite("Feed from CloudKit Conversion") + internal struct FromCloudKit { + @Test("init(from:) parses all fields correctly") + internal func testInitFromRecordAllFields() throws { + let fields: [String: FieldValue] = [ + "feedURL": .string("https://example.com/feed.xml"), + "title": .string("Test Feed"), + "description": .string("A description"), + "category": .string("Tech"), + "imageURL": .string("https://example.com/image.png"), + "siteURL": .string("https://example.com"), + "language": .string("en"), + "isFeatured": .int64(1), + "isVerified": .int64(0), + "isActive": .int64(1), + "qualityScore": .int64(80), + "subscriberCount": .int64(200), + "createdTimestamp": .date(Date(timeIntervalSince1970: 1_000_000)), + "verifiedTimestamp": .date(Date(timeIntervalSince1970: 2_000_000)), + "updateFrequency": .double(3_600.0), + "tags": .list([.string("tech"), .string("news")]), + "totalAttempts": .int64(10), + "successfulAttempts": .int64(8), + "attemptedTimestamp": .date(Date(timeIntervalSince1970: 3_000_000)), + "etag": .string("etag123"), + "lastModified": .string("Mon, 01 Jan 2024 00:00:00 GMT"), + "failureCount": .int64(2), + "lastFailureReason": .string("Timeout"), + "minUpdateInterval": .double(1_800.0), + ] + + let record = RecordInfo( + recordName: "test-record", + recordType: "Feed", + recordChangeTag: "change-tag", + fields: fields + ) + + let feed = try Feed(from: record) + + verifyRequiredFields(feed) + verifyOptionalStringFields(feed) + verifyBooleanFields(feed) + verifyNumericFields(feed) + verifyDateFields(feed) + verifyWebEtiquetteFields(feed) + } + + @Test("init(from:) handles missing optional fields with defaults") + internal func testInitFromRecordMissingFields() throws { + let fields: [String: FieldValue] = [ + "feedURL": .string("https://example.com/feed.xml"), + "title": .string("Minimal Feed"), + ] + + let record = RecordInfo( + recordName: "minimal-record", + recordType: "Feed", + recordChangeTag: nil, + fields: fields + ) + + let feed = try Feed(from: record) + + // Required fields should be set + #expect(feed.feedURL == "https://example.com/feed.xml") + #expect(feed.title == "Minimal Feed") + + // Optional fields should be nil or have defaults + #expect(feed.description == nil) + #expect(feed.category == nil) + #expect(feed.imageURL == nil) + #expect(feed.siteURL == nil) + #expect(feed.language == nil) + #expect(feed.isFeatured == false) + #expect(feed.isVerified == false) + #expect(feed.isActive == true) // Default is true + #expect(feed.qualityScore == 50) // Default + #expect(feed.subscriberCount == 0) + #expect(feed.totalAttempts == 0) + #expect(feed.successfulAttempts == 0) + #expect(feed.failureCount == 0) + #expect(feed.lastVerified == nil) + #expect(feed.updateFrequency == nil) + #expect(feed.tags.isEmpty) + #expect(feed.lastAttempted == nil) + #expect(feed.etag == nil) + #expect(feed.lastModified == nil) + #expect(feed.lastFailureReason == nil) + #expect(feed.minUpdateInterval == nil) + } + + // MARK: - Helper Methods + + private func verifyRequiredFields(_ feed: Feed) { + #expect(feed.recordName == "test-record") + #expect(feed.feedURL == "https://example.com/feed.xml") + #expect(feed.title == "Test Feed") + } + + private func verifyOptionalStringFields(_ feed: Feed) { + #expect(feed.description == "A description") + #expect(feed.category == "Tech") + #expect(feed.imageURL == "https://example.com/image.png") + #expect(feed.siteURL == "https://example.com") + #expect(feed.language == "en") + } + + private func verifyBooleanFields(_ feed: Feed) { + #expect(feed.isFeatured == true) + #expect(feed.isVerified == false) + #expect(feed.isActive == true) + } + + private func verifyNumericFields(_ feed: Feed) { + #expect(feed.qualityScore == 80) + #expect(feed.subscriberCount == 200) + #expect(feed.tags == ["tech", "news"]) + #expect(feed.totalAttempts == 10) + #expect(feed.successfulAttempts == 8) + #expect(feed.failureCount == 2) + #expect(feed.updateFrequency == 3_600.0) + #expect(feed.minUpdateInterval == 1_800.0) + } + + private func verifyDateFields(_ feed: Feed) { + #expect(feed.addedAt == Date(timeIntervalSince1970: 1_000_000)) + #expect(feed.lastVerified == Date(timeIntervalSince1970: 2_000_000)) + #expect(feed.lastAttempted == Date(timeIntervalSince1970: 3_000_000)) + } + + private func verifyWebEtiquetteFields(_ feed: Feed) { + #expect(feed.etag == "etag123") + #expect(feed.lastModified == "Mon, 01 Jan 2024 00:00:00 GMT") + #expect(feed.lastFailureReason == "Timeout") + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+RoundTrip.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+RoundTrip.swift new file mode 100644 index 00000000..018cb130 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+RoundTrip.swift @@ -0,0 +1,151 @@ +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension FeedConversion { + @Suite("Feed Round-Trip Conversion") + internal struct RoundTrip { + @Test("Round-trip conversion preserves data") + internal func testRoundTripConversion() throws { + let originalFeed = Feed( + recordName: "round-trip", + recordChangeTag: "tag1", + feedURL: "https://example.com/feed.xml", + title: "Round Trip Feed", + description: "Testing round-trip", + category: "Test", + imageURL: "https://example.com/img.png", + siteURL: "https://example.com", + language: "en", + isFeatured: true, + isVerified: true, + qualityScore: 90, + subscriberCount: 500, + addedAt: Date(timeIntervalSince1970: 1_000_000), + lastVerified: Date(timeIntervalSince1970: 2_000_000), + updateFrequency: 7_200.0, + tags: ["test", "roundtrip"], + totalAttempts: 15, + successfulAttempts: 14, + lastAttempted: Date(timeIntervalSince1970: 3_000_000), + isActive: true, + etag: "round123", + lastModified: "Thu, 01 Feb 2024 00:00:00 GMT", + failureCount: 1, + lastFailureReason: "Brief timeout", + minUpdateInterval: 3_600.0 + ) + + // Convert to fields + let fields = originalFeed.toFieldsDict() + + // Create a record + let record = RecordInfo( + recordName: originalFeed.recordName ?? "round-trip", + recordType: "Feed", + recordChangeTag: originalFeed.recordChangeTag, + fields: fields + ) + + // Convert back to Feed + let reconstructedFeed = try Feed(from: record) + + // Verify all fields match + verifyStringFields(reconstructedFeed, original: originalFeed) + verifyBooleanFields(reconstructedFeed, original: originalFeed) + verifyNumericFields(reconstructedFeed, original: originalFeed) + verifyWebEtiquetteFields(reconstructedFeed, original: originalFeed) + } + + @Test("Boolean fields correctly convert between Bool and Int64") + internal func testBooleanFieldConversion() throws { + let feed = Feed( + recordName: "bool-test", + recordChangeTag: nil, + feedURL: "https://example.com/feed.xml", + title: "Boolean Test", + description: nil, + category: nil, + imageURL: nil, + siteURL: nil, + language: nil, + isFeatured: true, // Should be 1 + isVerified: false, // Should be 0 + qualityScore: 50, + subscriberCount: 0, + addedAt: Date(), + lastVerified: nil, + updateFrequency: nil, + tags: [], + totalAttempts: 0, + successfulAttempts: 0, + lastAttempted: nil, + isActive: false, // Should be 0 + etag: nil, + lastModified: nil, + failureCount: 0, + lastFailureReason: nil, + minUpdateInterval: nil + ) + + let fields = feed.toFieldsDict() + + // Verify booleans are stored as Int64 + #expect(fields["isFeatured"] == .int64(1)) + #expect(fields["isVerified"] == .int64(0)) + #expect(fields["isActive"] == .int64(0)) + + // Round-trip back + let record = RecordInfo( + recordName: "bool-test", + recordType: "Feed", + recordChangeTag: nil, + fields: fields + ) + + let reconstructed = try Feed(from: record) + + #expect(reconstructed.isFeatured == true) + #expect(reconstructed.isVerified == false) + #expect(reconstructed.isActive == false) + } + + // MARK: - Helper Methods + + private func verifyStringFields(_ reconstructed: Feed, original: Feed) { + #expect(reconstructed.feedURL == original.feedURL) + #expect(reconstructed.title == original.title) + #expect(reconstructed.description == original.description) + #expect(reconstructed.category == original.category) + #expect(reconstructed.imageURL == original.imageURL) + #expect(reconstructed.siteURL == original.siteURL) + #expect(reconstructed.language == original.language) + } + + private func verifyBooleanFields(_ reconstructed: Feed, original: Feed) { + #expect(reconstructed.isFeatured == original.isFeatured) + #expect(reconstructed.isVerified == original.isVerified) + #expect(reconstructed.isActive == original.isActive) + } + + private func verifyNumericFields(_ reconstructed: Feed, original: Feed) { + #expect(reconstructed.qualityScore == original.qualityScore) + #expect(reconstructed.subscriberCount == original.subscriberCount) + #expect(reconstructed.tags == original.tags) + #expect(reconstructed.totalAttempts == original.totalAttempts) + #expect(reconstructed.successfulAttempts == original.successfulAttempts) + #expect(reconstructed.failureCount == original.failureCount) + #expect(reconstructed.updateFrequency == original.updateFrequency) + #expect(reconstructed.minUpdateInterval == original.minUpdateInterval) + } + + private func verifyWebEtiquetteFields(_ reconstructed: Feed, original: Feed) { + #expect(reconstructed.etag == original.etag) + #expect(reconstructed.lastModified == original.lastModified) + #expect(reconstructed.lastFailureReason == original.lastFailureReason) + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift new file mode 100644 index 00000000..61ff2a15 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift @@ -0,0 +1,173 @@ +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension FeedConversion { + @Suite("Feed to CloudKit Conversion") + internal struct ToCloudKit { + @Test("toFieldsDict converts required fields correctly") + internal func testToFieldsDictRequiredFields() { + let feed = Feed( + recordName: "test-feed", + recordChangeTag: nil, + feedURL: "https://example.com/feed.xml", + title: "Test Feed", + description: nil, + category: nil, + imageURL: nil, + siteURL: nil, + language: nil, + isFeatured: false, + isVerified: true, + qualityScore: 75, + subscriberCount: 100, + addedAt: Date(timeIntervalSince1970: 1_000_000), + lastVerified: nil, + updateFrequency: nil, + tags: [], + totalAttempts: 5, + successfulAttempts: 4, + lastAttempted: nil, + isActive: true, + etag: nil, + lastModified: nil, + failureCount: 1, + lastFailureReason: nil, + minUpdateInterval: nil + ) + + let fields = feed.toFieldsDict() + + // Check required string fields + #expect(fields["feedURL"] == .string("https://example.com/feed.xml")) + #expect(fields["title"] == .string("Test Feed")) + + // Check boolean fields stored as Int64 + #expect(fields["isFeatured"] == .int64(0)) + #expect(fields["isVerified"] == .int64(1)) + #expect(fields["isActive"] == .int64(1)) + + // Check numeric fields + #expect(fields["qualityScore"] == .int64(75)) + #expect(fields["subscriberCount"] == .int64(100)) + #expect(fields["totalAttempts"] == .int64(5)) + #expect(fields["successfulAttempts"] == .int64(4)) + #expect(fields["failureCount"] == .int64(1)) + + // Note: addedAt uses CloudKit's built-in createdTimestamp system field, not in dictionary + } + + @Test("toFieldsDict handles optional fields correctly") + internal func testToFieldsDictOptionalFields() { + let feed = Feed( + recordName: "test-feed", + recordChangeTag: nil, + feedURL: "https://example.com/feed.xml", + title: "Test Feed", + description: "A test description", + category: "Technology", + imageURL: "https://example.com/image.png", + siteURL: "https://example.com", + language: "en", + isFeatured: true, + isVerified: false, + qualityScore: 50, + subscriberCount: 0, + addedAt: Date(), + lastVerified: Date(timeIntervalSince1970: 2_000_000), + updateFrequency: 3_600.0, + tags: ["tech", "news"], + totalAttempts: 0, + successfulAttempts: 0, + lastAttempted: Date(timeIntervalSince1970: 3_000_000), + isActive: true, + etag: "abc123", + lastModified: "Mon, 01 Jan 2024 00:00:00 GMT", + failureCount: 0, + lastFailureReason: "Network error", + minUpdateInterval: 1_800.0 + ) + + let fields = feed.toFieldsDict() + + // Check optional string fields are present + #expect(fields["description"] == .string("A test description")) + #expect(fields["category"] == .string("Technology")) + #expect(fields["imageURL"] == .string("https://example.com/image.png")) + #expect(fields["siteURL"] == .string("https://example.com")) + #expect(fields["language"] == .string("en")) + #expect(fields["etag"] == .string("abc123")) + #expect(fields["lastModified"] == .string("Mon, 01 Jan 2024 00:00:00 GMT")) + #expect(fields["lastFailureReason"] == .string("Network error")) + + // Check optional date fields + #expect(fields["verifiedTimestamp"] == .date(Date(timeIntervalSince1970: 2_000_000))) + #expect(fields["attemptedTimestamp"] == .date(Date(timeIntervalSince1970: 3_000_000))) + + // Check optional numeric fields + #expect(fields["updateFrequency"] == .double(3_600.0)) + #expect(fields["minUpdateInterval"] == .double(1_800.0)) + + // Check array field + if case .list(let tagValues) = fields["tags"] { + #expect(tagValues.count == 2) + #expect(tagValues[0] == .string("tech")) + #expect(tagValues[1] == .string("news")) + } else { + Issue.record("tags field should be a list") + } + } + + @Test("toFieldsDict omits nil optional fields") + internal func testToFieldsDictOmitsNilFields() { + let feed = Feed( + recordName: "test-feed", + recordChangeTag: nil, + feedURL: "https://example.com/feed.xml", + title: "Test Feed", + description: nil, + category: nil, + imageURL: nil, + siteURL: nil, + language: nil, + isFeatured: false, + isVerified: false, + qualityScore: 50, + subscriberCount: 0, + addedAt: Date(), + lastVerified: nil, + updateFrequency: nil, + tags: [], + totalAttempts: 0, + successfulAttempts: 0, + lastAttempted: nil, + isActive: true, + etag: nil, + lastModified: nil, + failureCount: 0, + lastFailureReason: nil, + minUpdateInterval: nil + ) + + let fields = feed.toFieldsDict() + + // Verify optional fields are not present when nil + #expect(fields["description"] == nil) + #expect(fields["category"] == nil) + #expect(fields["imageURL"] == nil) + #expect(fields["siteURL"] == nil) + #expect(fields["language"] == nil) + #expect(fields["verifiedTimestamp"] == nil) + #expect(fields["updateFrequency"] == nil) + #expect(fields["attemptedTimestamp"] == nil) + #expect(fields["etag"] == nil) + #expect(fields["lastModified"] == nil) + #expect(fields["lastFailureReason"] == nil) + #expect(fields["minUpdateInterval"] == nil) + #expect(fields["tags"] == nil) + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion.swift new file mode 100644 index 00000000..e2e69be8 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion.swift @@ -0,0 +1,2 @@ +/// Namespace for Feed conversion tests +internal enum FeedConversion {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Mocks/MockCloudKitRecordOperator.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Mocks/MockCloudKitRecordOperator.swift new file mode 100644 index 00000000..a160f1e6 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Mocks/MockCloudKitRecordOperator.swift @@ -0,0 +1,86 @@ +// +// MockCloudKitRecordOperator.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +@testable import CelestraCloudKit + +/// Mock implementation of CloudKitRecordOperating for testing +internal final class MockCloudKitRecordOperator: CloudKitRecordOperating, @unchecked Sendable { + // MARK: - Recorded Calls + + internal struct QueryCall { + internal let recordType: String + internal let filters: [QueryFilter]? + internal let sortBy: [QuerySort]? + internal let limit: Int? + internal let desiredKeys: [String]? + } + + internal struct ModifyCall { + internal let operations: [RecordOperation] + } + + internal private(set) var queryCalls: [QueryCall] = [] + internal private(set) var modifyCalls: [ModifyCall] = [] + + // MARK: - Stubbed Results + + internal var queryRecordsResult: Result<[RecordInfo], CloudKitError> = .success([]) + internal var modifyRecordsResult: Result<[RecordInfo], CloudKitError> = .success([]) + + // MARK: - CloudKitRecordOperating + + internal func queryRecords( + recordType: String, + filters: [QueryFilter]?, + sortBy: [QuerySort]?, + limit: Int?, + desiredKeys: [String]? + ) async throws(CloudKitError) -> [RecordInfo] { + queryCalls.append( + QueryCall( + recordType: recordType, + filters: filters, + sortBy: sortBy, + limit: limit, + desiredKeys: desiredKeys + ) + ) + return try queryRecordsResult.get() + } + + internal func modifyRecords(_ operations: [RecordOperation]) async throws(CloudKitError) + -> [RecordInfo] + { + modifyCalls.append(ModifyCall(operations: operations)) + return try modifyRecordsResult.get() + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Models/BatchOperationResultTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Models/BatchOperationResultTests.swift new file mode 100644 index 00000000..95b7daaf --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Models/BatchOperationResultTests.swift @@ -0,0 +1,193 @@ +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +@Suite("BatchOperationResult Tests") +internal struct BatchOperationResultTests { + @Test("Success rate with all successes") + internal func testSuccessRateAllSuccess() { + var result = BatchOperationResult() + + // Add 5 successful records + let successRecords = createTestRecords(count: 5) + result.appendSuccesses(successRecords) + + #expect(result.successCount == 5) + #expect(result.failureCount == 0) + #expect(result.totalProcessed == 5) + #expect(result.successRate == 100.0) + #expect(result.isFullSuccess == true) + #expect(result.isFullFailure == false) + } + + @Test("Success rate with all failures") + internal func testSuccessRateAllFailure() { + var result = BatchOperationResult() + + // Add 3 failed records + let failedArticles = createTestArticles(count: 3) + let testError = NSError(domain: "Test", code: 1, userInfo: nil) + + for article in failedArticles { + result.appendFailure(article: article, error: testError) + } + + #expect(result.successCount == 0) + #expect(result.failureCount == 3) + #expect(result.totalProcessed == 3) + #expect(result.successRate == 0.0) + #expect(result.isFullSuccess == false) + #expect(result.isFullFailure == true) + } + + @Test("Success rate with mixed results") + internal func testSuccessRateMixed() { + var result = BatchOperationResult() + + // Add 6 successes + result.appendSuccesses(createTestRecords(count: 6)) + + // Add 4 failures + let testError = NSError(domain: "Test", code: 1, userInfo: nil) + for article in createTestArticles(count: 4) { + result.appendFailure(article: article, error: testError) + } + + #expect(result.successCount == 6) + #expect(result.failureCount == 4) + #expect(result.totalProcessed == 10) + #expect(result.successRate == 60.0) + #expect(result.isFullSuccess == false) + #expect(result.isFullFailure == false) + } + + @Test("Success rate with empty result") + internal func testSuccessRateEmpty() { + let result = BatchOperationResult() + + #expect(result.successCount == 0) + #expect(result.failureCount == 0) + #expect(result.totalProcessed == 0) + #expect(result.successRate == 0.0) + #expect(result.isFullSuccess == false) + #expect(result.isFullFailure == false) + } + + @Test("Append combines two results") + internal func testAppendCombinesResults() { + var result1 = BatchOperationResult() + result1.appendSuccesses(createTestRecords(count: 3)) + + let testError = NSError(domain: "Test", code: 1, userInfo: nil) + result1.appendFailure(article: createTestArticles(count: 1)[0], error: testError) + + var result2 = BatchOperationResult() + result2.appendSuccesses(createTestRecords(count: 2)) + result2.appendFailure(article: createTestArticles(count: 1)[0], error: testError) + + // Append result2 to result1 + result1.append(result2) + + #expect(result1.successCount == 5) // 3 + 2 + #expect(result1.failureCount == 2) // 1 + 1 + #expect(result1.totalProcessed == 7) + #expect(result1.successRate == (5.0 / 7.0) * 100.0) + } + + @Test("IsFullSuccess only true when all succeed") + internal func testIsFullSuccess() { + var result = BatchOperationResult() + + // Empty result - not full success + #expect(result.isFullSuccess == false) + + // Add successes only + result.appendSuccesses(createTestRecords(count: 3)) + #expect(result.isFullSuccess == true) + + // Add a failure - no longer full success + let testError = NSError(domain: "Test", code: 1, userInfo: nil) + result.appendFailure(article: createTestArticles(count: 1)[0], error: testError) + #expect(result.isFullSuccess == false) + } + + @Test("IsFullFailure only true when all fail") + internal func testIsFullFailure() { + var result = BatchOperationResult() + let testError = NSError(domain: "Test", code: 1, userInfo: nil) + + // Empty result - not full failure + #expect(result.isFullFailure == false) + + // Add failures only + for article in createTestArticles(count: 3) { + result.appendFailure(article: article, error: testError) + } + #expect(result.isFullFailure == true) + + // Add a success - no longer full failure + result.appendSuccesses(createTestRecords(count: 1)) + #expect(result.isFullFailure == false) + } + + @Test("AppendSuccesses adds multiple records") + internal func testAppendSuccesses() { + var result = BatchOperationResult() + + let records = createTestRecords(count: 5) + result.appendSuccesses(records) + + #expect(result.successCount == 5) + #expect(result.successfulRecords.count == 5) + } + + @Test("AppendFailure adds single failure") + internal func testAppendFailure() { + var result = BatchOperationResult() + + let article = createTestArticles(count: 1)[0] + let testError = NSError(domain: "TestDomain", code: 42, userInfo: nil) + + result.appendFailure(article: article, error: testError) + + #expect(result.failureCount == 1) + #expect(result.failedRecords.count == 1) + #expect(result.failedRecords[0].article.guid == article.guid) + } + + // MARK: - Helper Methods + + /// Create test RecordInfo objects + private func createTestRecords(count: Int) -> [RecordInfo] { + (0.. [Article] { + (0.. FeedItem { + FeedItem( + title: title, + link: link, + description: description, + content: content, + author: author, + pubDate: pubDate, + guid: guid + ) + } + + private func createArticle( + recordName: String? = nil, + recordChangeTag: String? = nil, + feedRecordName: String = "feed-123", + guid: String, + title: String = "Test Title", + url: String = "https://example.com/article", + excerpt: String? = "Test description", + content: String? = "Test content", + author: String? = "Test Author", + publishedDate: Date? = Date(timeIntervalSince1970: 1_000_000) + ) -> Article { + Article( + recordName: recordName, + recordChangeTag: recordChangeTag, + feedRecordName: feedRecordName, + guid: guid, + title: title, + excerpt: excerpt, + content: content, + author: author, + url: url, + publishedDate: publishedDate + ) + } + + // MARK: - Tests + + @Test("Mixed scenario: new, modified, and unchanged") + internal func testMixedScenario() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + + let items = [ + createFeedItem(guid: "new-1", title: "New Article"), + createFeedItem(guid: "modified-1", title: "Modified Title"), + createFeedItem(guid: "unchanged-1", title: "Unchanged"), + ] + + let existing = [ + createArticle( + recordName: "record-mod", + guid: "modified-1", + title: "Original Title" + ), + createArticle( + recordName: "record-unch", + guid: "unchanged-1", + title: "Unchanged" + ), + ] + + let result = categorizer.categorize( + items: items, + existingArticles: existing, + feedRecordName: "feed-123" + ) + + #expect(result.new.count == 1) + #expect(result.new[0].guid == "new-1") + + #expect(result.modified.count == 1) + #expect(result.modified[0].guid == "modified-1") + #expect(result.modified[0].recordName == "record-mod") + } + + @Test("Duplicate GUIDs within feed items") + internal func testDuplicateGUIDsInItems() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + + let items = [ + createFeedItem(guid: "dup-1", title: "First"), + createFeedItem(guid: "dup-1", title: "Second"), + ] + + let result = categorizer.categorize( + items: items, + existingArticles: [], + feedRecordName: "feed-123" + ) + + // Both should be treated as new (no deduplication within items) + #expect(result.new.count == 2) + } + + @Test("Existing articles with no matching items") + internal func testExistingWithNoMatchingItems() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + + let existing = [ + createArticle(recordName: "record-1", guid: "old-1"), + createArticle(recordName: "record-2", guid: "old-2"), + ] + + let items = [createFeedItem(guid: "new-1")] + + let result = categorizer.categorize( + items: items, + existingArticles: existing, + feedRecordName: "feed-123" + ) + + #expect(result.new.count == 1) + #expect(result.modified.isEmpty) + // Old articles are not deleted - that's a separate operation + } + + @Test("Feed record name is correctly assigned to new articles") + internal func testFeedRecordNameAssignment() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + let customFeedRecordName = "custom-feed-456" + + let items = [createFeedItem(guid: "guid-1")] + + let result = categorizer.categorize( + items: items, + existingArticles: [], + feedRecordName: customFeedRecordName + ) + + #expect(result.new.count == 1) + #expect(result.new[0].feedRecordName == customFeedRecordName) + } + + @Test("Items with no existing articles") + internal func testNoExistingArticles() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + let items = [createFeedItem(guid: "guid-1")] + + let result = categorizer.categorize( + items: items, + existingArticles: [], + feedRecordName: "feed-123" + ) + + #expect(result.new.count == 1) + #expect(result.modified.isEmpty) + } + + @Test("CloudKit metadata preservation for modified articles") + internal func testCloudKitMetadataPreservation() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + + let item = createFeedItem(guid: "guid-1", title: "New Title") + let existing = createArticle( + recordName: "record-abc-123", + recordChangeTag: "tag-xyz-789", + guid: "guid-1", + title: "Old Title" + ) + + let result = categorizer.categorize( + items: [item], + existingArticles: [existing], + feedRecordName: "feed-123" + ) + + #expect(result.modified.count == 1) + let modified = result.modified[0] + + // CloudKit metadata must be preserved + #expect(modified.recordName == "record-abc-123") + #expect(modified.recordChangeTag == "tag-xyz-789") + + // Content must be updated + #expect(modified.title == "New Title") + #expect(modified.guid == "guid-1") + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Basic.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Basic.swift new file mode 100644 index 00000000..58ac2d58 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Basic.swift @@ -0,0 +1,182 @@ +// +// ArticleCategorizer+Basic.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraKit +import Foundation +import Testing + +@testable import CelestraCloudKit + +extension ArticleCategorizer { + @Suite("Basic Scenarios") + internal struct Basic { + // MARK: - Test Fixtures + + private func createFeedItem( + guid: String, + title: String = "Test Title", + link: String = "https://example.com/article", + description: String? = "Test description", + content: String? = "Test content", + author: String? = "Test Author", + pubDate: Date? = Date(timeIntervalSince1970: 1_000_000) + ) -> FeedItem { + FeedItem( + title: title, + link: link, + description: description, + content: content, + author: author, + pubDate: pubDate, + guid: guid + ) + } + + private func createArticle( + recordName: String? = nil, + recordChangeTag: String? = nil, + feedRecordName: String = "feed-123", + guid: String, + title: String = "Test Title", + url: String = "https://example.com/article", + excerpt: String? = "Test description", + content: String? = "Test content", + author: String? = "Test Author", + publishedDate: Date? = Date(timeIntervalSince1970: 1_000_000) + ) -> Article { + Article( + recordName: recordName, + recordChangeTag: recordChangeTag, + feedRecordName: feedRecordName, + guid: guid, + title: title, + excerpt: excerpt, + content: content, + author: author, + url: url, + publishedDate: publishedDate + ) + } + + // MARK: - Tests + + @Test("Empty inputs returns empty result") + internal func testEmptyInputs() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + + let result = categorizer.categorize( + items: [], + existingArticles: [], + feedRecordName: "feed-123" + ) + + #expect(result.new.isEmpty) + #expect(result.modified.isEmpty) + } + + @Test("All new articles when no existing") + internal func testAllNewArticles() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + let items = [ + createFeedItem(guid: "guid-1"), + createFeedItem(guid: "guid-2"), + createFeedItem(guid: "guid-3"), + ] + + let result = categorizer.categorize( + items: items, + existingArticles: [], + feedRecordName: "feed-123" + ) + + #expect(result.new.count == 3) + #expect(result.modified.isEmpty) + #expect(result.new.map(\.guid) == ["guid-1", "guid-2", "guid-3"]) + #expect(result.new.allSatisfy { $0.feedRecordName == "feed-123" }) + #expect(result.new.allSatisfy { $0.recordName == nil }) // New articles don't have recordName + } + + @Test("All unchanged when GUID and contentHash match") + internal func testAllUnchanged() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + + // Create articles with matching content (same contentHash) + let item = createFeedItem( + guid: "guid-1", + title: "Title", + link: "https://example.com/1" + ) + let existing = createArticle( + recordName: "record-1", + recordChangeTag: "tag-1", + guid: "guid-1", + title: "Title", + url: "https://example.com/1" + ) + + let result = categorizer.categorize( + items: [item], + existingArticles: [existing], + feedRecordName: "feed-123" + ) + + #expect(result.new.isEmpty) + #expect(result.modified.isEmpty) // Unchanged articles are skipped + } + + @Test("Modified articles when GUID matches but contentHash differs") + internal func testModifiedArticles() { + let categorizer = CelestraCloudKit.ArticleCategorizer() + + // Create item with different title (different contentHash) + let item = createFeedItem(guid: "guid-1", title: "Updated Title") + let existing = createArticle( + recordName: "record-1", + recordChangeTag: "tag-1", + guid: "guid-1", + title: "Original Title" + ) + + let result = categorizer.categorize( + items: [item], + existingArticles: [existing], + feedRecordName: "feed-123" + ) + + #expect(result.new.isEmpty) + #expect(result.modified.count == 1) + + let modified = result.modified[0] + #expect(modified.recordName == "record-1") // Preserved + #expect(modified.recordChangeTag == "tag-1") // Preserved + #expect(modified.guid == "guid-1") + #expect(modified.title == "Updated Title") // Updated content + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer.swift new file mode 100644 index 00000000..f02ff0ee --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer.swift @@ -0,0 +1,2 @@ +/// Namespace for ArticleCategorizer tests +internal enum ArticleCategorizer {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Mutations.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Mutations.swift new file mode 100644 index 00000000..9db8260a --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Mutations.swift @@ -0,0 +1,203 @@ +// +// ArticleCloudKitService+Mutations.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension ArticleCloudKitService { + @Suite("ArticleCloudKitService Mutations Tests") + internal struct MutationsTests { + // MARK: - Test Fixtures + + private func createMockRecordInfo( + recordName: String = "test-record", + fields: [String: FieldValue] = [:] + ) -> RecordInfo { + RecordInfo( + recordName: recordName, + recordType: "Article", + recordChangeTag: "tag-123", + fields: fields + ) + } + + private func createTestArticle( + recordName: String? = nil, + guid: String = "test-guid" + ) -> Article { + Article( + recordName: recordName, + feedRecordName: "feed-123", + guid: guid, + title: "Test Article", + url: "https://example.com/article", + fetchedAt: Date(timeIntervalSince1970: 1_000_000), + ttlDays: 30 + ) + } + + private func createArticleRecordFields(guid: String = "test-guid") -> [String: FieldValue] { + [ + "feedRecordName": .string("feed-123"), + "guid": .string(guid), + "title": .string("Test Article"), + "url": .string("https://example.com/article"), + "fetchedTimestamp": .date(Date(timeIntervalSince1970: 1_000_000)), + "expiresTimestamp": .date(Date(timeIntervalSince1970: 1_000_000 + 30 * 24 * 60 * 60)), + "contentHash": .string("abc123"), + ] + } + + // MARK: - createArticles Tests + + @Test("createArticles returns empty result for empty input") + internal func testCreateArticlesEmptyArray() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + let result = try await service.createArticles([]) + + #expect(result.totalProcessed == 0) + #expect(mock.modifyCalls.isEmpty) + } + + @Test("createArticles creates articles with correct operations") + internal func testCreateArticlesCreatesOperations() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + let articles = [createTestArticle(guid: "guid-1"), createTestArticle(guid: "guid-2")] + + let mockRecords = [ + createMockRecordInfo(recordName: "new-1"), + createMockRecordInfo(recordName: "new-2"), + ] + mock.modifyRecordsResult = .success(mockRecords) + + let result = try await service.createArticles(articles) + + #expect(result.successCount == 2) + #expect(mock.modifyCalls.count == 1) + + let operations = mock.modifyCalls[0].operations + #expect(operations.count == 2) + #expect(operations[0].operationType == .create) + #expect(operations[0].recordType == "Article") + } + + @Test("createArticles batches large article lists") + internal func testCreateArticlesBatchesCorrectly() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + // Create 25 articles to trigger batching (batch size is 10) + let articles = (0..<25).map { createTestArticle(guid: "guid-\($0)") } + mock.modifyRecordsResult = .success([createMockRecordInfo()]) + + _ = try await service.createArticles(articles) + + // Should have made 3 modify calls (10 + 10 + 5) + #expect(mock.modifyCalls.count == 3) + } + + // MARK: - updateArticles Tests + + @Test("updateArticles returns empty result for empty input") + internal func testUpdateArticlesEmptyArray() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + let result = try await service.updateArticles([]) + + #expect(result.totalProcessed == 0) + #expect(mock.modifyCalls.isEmpty) + } + + @Test("updateArticles skips articles without recordName") + internal func testUpdateArticlesSkipsArticlesWithoutRecordName() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + // Article without recordName + let article = createTestArticle(recordName: nil) + + let result = try await service.updateArticles([article]) + + #expect(result.totalProcessed == 0) + #expect(mock.modifyCalls.isEmpty) + } + + @Test("updateArticles creates update operations for valid articles") + internal func testUpdateArticlesCreatesOperations() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + let article = createTestArticle(recordName: "existing-article") + mock.modifyRecordsResult = .success([createMockRecordInfo(recordName: "existing-article")]) + + let result = try await service.updateArticles([article]) + + #expect(result.successCount == 1) + #expect(mock.modifyCalls.count == 1) + + let operations = mock.modifyCalls[0].operations + #expect(operations.count == 1) + #expect(operations[0].operationType == .update) + #expect(operations[0].recordName == "existing-article") + } + + // MARK: - deleteAllArticles Tests + + @Test("deleteAllArticles deletes articles in batches") + internal func testDeleteAllArticles() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + let article1 = createMockRecordInfo(recordName: "article-1") + let article2 = createMockRecordInfo(recordName: "article-2") + + mock.queryRecordsResult = .success([article1, article2]) + mock.modifyRecordsResult = .success([]) + + try await service.deleteAllArticles() + + #expect(mock.queryCalls.count >= 1) + #expect(mock.modifyCalls.count >= 1) + + if let modifyCall = mock.modifyCalls.first { + for operation in modifyCall.operations { + #expect(operation.operationType == .delete) + } + } + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Query.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Query.swift new file mode 100644 index 00000000..2ec566de --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Query.swift @@ -0,0 +1,158 @@ +// +// ArticleCloudKitService+Query.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension ArticleCloudKitService { + @Suite("ArticleCloudKitService Query Tests") + internal struct QueryTests { + // MARK: - Test Fixtures + + private func createMockRecordInfo( + recordName: String = "test-record", + fields: [String: FieldValue] = [:] + ) -> RecordInfo { + RecordInfo( + recordName: recordName, + recordType: "Article", + recordChangeTag: "tag-123", + fields: fields + ) + } + + private func createTestArticle( + recordName: String? = nil, + guid: String = "test-guid" + ) -> Article { + Article( + recordName: recordName, + feedRecordName: "feed-123", + guid: guid, + title: "Test Article", + url: "https://example.com/article", + fetchedAt: Date(timeIntervalSince1970: 1_000_000), + ttlDays: 30 + ) + } + + private func createArticleRecordFields(guid: String = "test-guid") -> [String: FieldValue] { + [ + "feedRecordName": .string("feed-123"), + "guid": .string(guid), + "title": .string("Test Article"), + "url": .string("https://example.com/article"), + "fetchedTimestamp": .date(Date(timeIntervalSince1970: 1_000_000)), + "expiresTimestamp": .date(Date(timeIntervalSince1970: 1_000_000 + 30 * 24 * 60 * 60)), + "contentHash": .string("abc123"), + ] + } + + // MARK: - queryArticlesByGUIDs Tests + + @Test("queryArticlesByGUIDs returns empty array for empty GUIDs") + internal func testQueryArticlesByGUIDsEmptyGUIDs() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + let result = try await service.queryArticlesByGUIDs([]) + + #expect(result.isEmpty) + #expect(mock.queryCalls.isEmpty) + } + + @Test("queryArticlesByGUIDs queries with GUID filter") + internal func testQueryArticlesByGUIDsFiltersArticles() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + let fields = createArticleRecordFields(guid: "guid-1") + mock.queryRecordsResult = .success([ + createMockRecordInfo(recordName: "article-1", fields: fields) + ]) + + let result = try await service.queryArticlesByGUIDs(["guid-1", "guid-2"]) + + #expect(result.count == 1) + #expect(mock.queryCalls.count == 1) + #expect(mock.queryCalls[0].recordType == "Article") + #expect(mock.queryCalls[0].filters != nil) + } + + @Test("queryArticlesByGUIDs applies feedRecordName filter when provided") + internal func testQueryArticlesByGUIDsFiltersByFeed() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + // Mock returns 2 articles: one matching feed, one not + let matchingFields = createArticleRecordFields(guid: "guid-1") + let nonMatchingFields = createArticleRecordFields(guid: "guid-2") + .merging(["feedRecordName": .string("other-feed")]) { _, new in new } + + mock.queryRecordsResult = .success([ + createMockRecordInfo(recordName: "article-1", fields: matchingFields), + createMockRecordInfo(recordName: "article-2", fields: nonMatchingFields) + ]) + + let result = try await service.queryArticlesByGUIDs( + ["guid-1", "guid-2"], + feedRecordName: "feed-123" + ) + + // Verify CloudKit query behavior + #expect(mock.queryCalls.count == 1) + // Should have 1 filter (GUID only), feedRecordName filtered in-memory + #expect(mock.queryCalls[0].filters?.count == 1) + + // Verify in-memory filtering works + #expect(result.count == 1) // Only matching article returned + #expect(result[0].guid == "guid-1") + #expect(result[0].feedRecordName == "feed-123") + } + + @Test("queryArticlesByGUIDs batches large GUID lists") + internal func testQueryArticlesByGUIDsBatchesLargeGUIDLists() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock) + + // Create 200 GUIDs to trigger batching (batch size is 150) + let guids = (0..<200).map { "guid-\($0)" } + mock.queryRecordsResult = .success([]) + + _ = try await service.queryArticlesByGUIDs(guids) + + // Should have made 2 query calls (150 + 50) + #expect(mock.queryCalls.count == 2) + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService.swift new file mode 100644 index 00000000..32714368 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService.swift @@ -0,0 +1,2 @@ +/// Namespace for ArticleCloudKitService tests +internal enum ArticleCloudKitService {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+CRUD.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+CRUD.swift new file mode 100644 index 00000000..629b25e0 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+CRUD.swift @@ -0,0 +1,176 @@ +// +// FeedCloudKitService+CRUD.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension FeedCloudKitService { + @Suite("FeedCloudKitService CRUD Operations") + internal struct CRUDTests { + // MARK: - Test Fixtures + + private func createMockRecordInfo( + recordName: String = "test-record", + fields: [String: FieldValue] = [:] + ) -> RecordInfo { + RecordInfo( + recordName: recordName, + recordType: "Feed", + recordChangeTag: "tag-123", + fields: fields + ) + } + + private func createTestFeed() -> Feed { + Feed( + recordName: nil, + feedURL: "https://example.com/feed.xml", + title: "Test Feed", + description: "A test feed", + isFeatured: false, + isVerified: true, + subscriberCount: 100, + totalAttempts: 5, + successfulAttempts: 4, + lastAttempted: Date(timeIntervalSince1970: 1_000_000), + isActive: true, + etag: "etag-123", + lastModified: "Mon, 01 Jan 2024 00:00:00 GMT", + failureCount: 1, + minUpdateInterval: 3_600 + ) + } + + // MARK: - createFeed Tests + + @Test("createFeed calls modifyRecords with create operation") + internal func testCreateFeedCallsModifyRecords() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + let feed = createTestFeed() + + let expectedRecord = createMockRecordInfo(recordName: "new-feed-id") + mock.modifyRecordsResult = .success([expectedRecord]) + + let result = try await service.createFeed(feed) + + #expect(mock.modifyCalls.count == 1) + #expect(result.recordName == "new-feed-id") + + // Verify the operation was a create + let operations = mock.modifyCalls[0].operations + #expect(operations.count == 1) + let operation = operations[0] + #expect(operation.operationType == .create) + #expect(operation.recordType == "Feed") + #expect(operation.fields["feedURL"] == .string("https://example.com/feed.xml")) + #expect(operation.fields["title"] == .string("Test Feed")) + } + + @Test("createFeed throws when modifyRecords returns empty array") + internal func testCreateFeedThrowsOnEmptyResponse() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + let feed = createTestFeed() + + mock.modifyRecordsResult = .success([]) + + await #expect(throws: CloudKitError.self) { + _ = try await service.createFeed(feed) + } + } + + // MARK: - updateFeed Tests + + @Test("updateFeed calls modifyRecords with update operation") + internal func testUpdateFeedCallsModifyRecords() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + let feed = Feed( + recordName: "existing-feed", + recordChangeTag: "old-tag", + feedURL: "https://example.com/updated.xml", + title: "Updated Feed" + ) + + let expectedRecord = createMockRecordInfo(recordName: "existing-feed") + mock.modifyRecordsResult = .success([expectedRecord]) + + let result = try await service.updateFeed(recordName: "existing-feed", feed: feed) + + #expect(mock.modifyCalls.count == 1) + #expect(result.recordName == "existing-feed") + + // Verify the operation was an update + let operations = mock.modifyCalls[0].operations + #expect(operations.count == 1) + let operation = operations[0] + #expect(operation.operationType == .update) + #expect(operation.recordType == "Feed") + #expect(operation.recordName == "existing-feed") + #expect(operation.fields["title"] == .string("Updated Feed")) + #expect(operation.recordChangeTag == "old-tag") + } + + // MARK: - deleteAllFeeds Tests + + @Test("deleteAllFeeds deletes all feeds in batches") + internal func testDeleteAllFeedsDeletesInBatches() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + + // First query returns 2 feeds, second query returns empty (done) + let feed1 = createMockRecordInfo(recordName: "feed-1", fields: ["feedURL": .string("url1")]) + let feed2 = createMockRecordInfo(recordName: "feed-2", fields: ["feedURL": .string("url2")]) + + // We can't easily do this with the current mock, so we'll just test the basic case + mock.queryRecordsResult = .success([feed1, feed2]) + mock.modifyRecordsResult = .success([]) + + // For this test, we'll verify it makes the right calls + // The actual implementation loops, but we can verify the pattern + try await service.deleteAllFeeds() + + // Should have made at least one query and one modify call + #expect(mock.queryCalls.count >= 1) + #expect(mock.modifyCalls.count >= 1) + + // Verify delete operations were created + if let modifyCall = mock.modifyCalls.first { + for operation in modifyCall.operations { + #expect(operation.operationType == .delete) + } + } + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+Query.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+Query.swift new file mode 100644 index 00000000..ab4e8366 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+Query.swift @@ -0,0 +1,163 @@ +// +// FeedCloudKitService+Query.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraKit +import Foundation +import MistKit +import Testing + +@testable import CelestraCloudKit + +extension FeedCloudKitService { + @Suite("FeedCloudKitService Query Operations") + internal struct QueryTests { + // MARK: - Test Fixtures + + private func createMockRecordInfo( + recordName: String = "test-record", + fields: [String: FieldValue] = [:] + ) -> RecordInfo { + RecordInfo( + recordName: recordName, + recordType: "Feed", + recordChangeTag: "tag-123", + fields: fields + ) + } + + private func createTestFeed() -> Feed { + Feed( + recordName: nil, + feedURL: "https://example.com/feed.xml", + title: "Test Feed", + description: "A test feed", + isFeatured: false, + isVerified: true, + subscriberCount: 100, + totalAttempts: 5, + successfulAttempts: 4, + lastAttempted: Date(timeIntervalSince1970: 1_000_000), + isActive: true, + etag: "etag-123", + lastModified: "Mon, 01 Jan 2024 00:00:00 GMT", + failureCount: 1, + minUpdateInterval: 3_600 + ) + } + + // MARK: - queryFeeds Tests + + @Test("queryFeeds returns feeds from query results") + internal func testQueryFeedsReturnsFeeds() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + + let feedFields: [String: FieldValue] = [ + "feedURL": .string("https://example.com/feed.xml"), + "title": .string("Test Feed"), + "isActive": .int64(1), + "isFeatured": .int64(0), + "isVerified": .int64(1), + "subscriberCount": .int64(50), + "totalAttempts": .int64(10), + "successfulAttempts": .int64(9), + "failureCount": .int64(1), + ] + let mockRecord = createMockRecordInfo(recordName: "feed-1", fields: feedFields) + mock.queryRecordsResult = .success([mockRecord]) + + let feeds = try await service.queryFeeds() + + #expect(feeds.count == 1) + #expect(feeds[0].feedURL == "https://example.com/feed.xml") + #expect(feeds[0].title == "Test Feed") + #expect(feeds[0].recordName == "feed-1") + } + + @Test("queryFeeds applies date filter when provided") + internal func testQueryFeedsAppliesDateFilter() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + let cutoffDate = Date(timeIntervalSince1970: 1_000_000) + + mock.queryRecordsResult = .success([]) + + _ = try await service.queryFeeds(lastAttemptedBefore: cutoffDate) + + #expect(mock.queryCalls.count == 1) + let call = mock.queryCalls[0] + #expect(call.recordType == "Feed") + #expect(call.filters != nil) + #expect(call.filters?.count == 1) + } + + @Test("queryFeeds applies popularity filter when provided") + internal func testQueryFeedsAppliesPopularityFilter() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + + mock.queryRecordsResult = .success([]) + + _ = try await service.queryFeeds(minPopularity: 100) + + #expect(mock.queryCalls.count == 1) + let call = mock.queryCalls[0] + #expect(call.filters != nil) + #expect(call.filters?.count == 1) + } + + @Test("queryFeeds applies both filters when provided") + internal func testQueryFeedsAppliesBothFilters() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + let cutoffDate = Date(timeIntervalSince1970: 1_000_000) + + mock.queryRecordsResult = .success([]) + + _ = try await service.queryFeeds(lastAttemptedBefore: cutoffDate, minPopularity: 50) + + #expect(mock.queryCalls.count == 1) + let call = mock.queryCalls[0] + #expect(call.filters?.count == 2) + } + + @Test("queryFeeds respects limit parameter") + internal func testQueryFeedsRespectsLimit() async throws { + let mock = MockCloudKitRecordOperator() + let service = CelestraCloudKit.FeedCloudKitService(recordOperator: mock) + + mock.queryRecordsResult = .success([]) + + _ = try await service.queryFeeds(limit: 50) + + #expect(mock.queryCalls.count == 1) + #expect(mock.queryCalls[0].limit == 50) + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService.swift new file mode 100644 index 00000000..5afd86bc --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService.swift @@ -0,0 +1,2 @@ +/// Namespace for FeedCloudKitService tests +internal enum FeedCloudKitService {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Error.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Error.swift new file mode 100644 index 00000000..5d952872 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Error.swift @@ -0,0 +1,135 @@ +// +// FeedMetadataBuilder+Error.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraKit +import Foundation +import Testing + +@testable import CelestraCloudKit + +extension FeedMetadataBuilder { + @Suite("Error Metadata Tests") + internal struct ErrorTests { + // MARK: - Test Fixtures + + private func createFeed( + title: String = "Original Title", + description: String? = "Original Description", + etag: String? = "original-etag", + lastModified: String? = "Mon, 01 Jan 2024 00:00:00 GMT", + minUpdateInterval: TimeInterval? = 3_600, + totalAttempts: Int64 = 10, + successfulAttempts: Int64 = 8, + failureCount: Int64 = 2 + ) -> Feed { + Feed( + recordName: "feed-123", + feedURL: "https://example.com/feed.xml", + title: title, + description: description, + totalAttempts: totalAttempts, + successfulAttempts: successfulAttempts, + etag: etag, + lastModified: lastModified, + failureCount: failureCount, + minUpdateInterval: minUpdateInterval + ) + } + + private func createFeedData( + title: String = "New Feed Title", + description: String? = "New Feed Description", + minUpdateInterval: TimeInterval? = 7_200 + ) -> FeedData { + FeedData( + title: title, + description: description, + items: [], // Not used in metadata building + minUpdateInterval: minUpdateInterval + ) + } + + private func createFetchResponse( + feedData: FeedData? = nil, + etag: String? = "new-etag", + lastModified: String? = "Tue, 02 Jan 2024 00:00:00 GMT" + ) -> FetchResponse { + FetchResponse( + feedData: feedData, + lastModified: lastModified, + etag: etag, + wasModified: feedData != nil + ) + } + + // MARK: - Error Metadata Tests + + @Test("Error metadata preserves all feed data") + internal func testErrorMetadataPreservesAllData() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed( + title: "Feed Title", + description: "Feed Description", + etag: "feed-etag", + lastModified: "feed-date", + minUpdateInterval: 1_800 + ) + + let metadata = builder.buildErrorMetadata( + feed: feed, + totalAttempts: 11 + ) + + // Everything preserved + #expect(metadata.title == "Feed Title") + #expect(metadata.description == "Feed Description") + #expect(metadata.etag == "feed-etag") + #expect(metadata.lastModified == "feed-date") + #expect(metadata.minUpdateInterval == 1_800) + } + + @Test("Error metadata increments failure count") + internal func testErrorIncrementsFailureCount() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed( + successfulAttempts: 8, + failureCount: 2 + ) + + let metadata = builder.buildErrorMetadata( + feed: feed, + totalAttempts: 11 + ) + + #expect(metadata.successfulAttempts == 8) // No change on error + #expect(metadata.failureCount == 3) // 2 + 1 + #expect(metadata.totalAttempts == 11) + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+NotModified.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+NotModified.swift new file mode 100644 index 00000000..fdadd0d4 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+NotModified.swift @@ -0,0 +1,188 @@ +// +// FeedMetadataBuilder+NotModified.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraKit +import Foundation +import Testing + +@testable import CelestraCloudKit + +extension FeedMetadataBuilder { + @Suite("Not Modified Metadata Tests") + internal struct NotModifiedTests { + // MARK: - Test Fixtures + + private func createFeed( + title: String = "Original Title", + description: String? = "Original Description", + etag: String? = "original-etag", + lastModified: String? = "Mon, 01 Jan 2024 00:00:00 GMT", + minUpdateInterval: TimeInterval? = 3_600, + totalAttempts: Int64 = 10, + successfulAttempts: Int64 = 8, + failureCount: Int64 = 2 + ) -> Feed { + Feed( + recordName: "feed-123", + feedURL: "https://example.com/feed.xml", + title: title, + description: description, + totalAttempts: totalAttempts, + successfulAttempts: successfulAttempts, + etag: etag, + lastModified: lastModified, + failureCount: failureCount, + minUpdateInterval: minUpdateInterval + ) + } + + private func createFeedData( + title: String = "New Feed Title", + description: String? = "New Feed Description", + minUpdateInterval: TimeInterval? = 7_200 + ) -> FeedData { + FeedData( + title: title, + description: description, + items: [], // Not used in metadata building + minUpdateInterval: minUpdateInterval + ) + } + + private func createFetchResponse( + feedData: FeedData? = nil, + etag: String? = "new-etag", + lastModified: String? = "Tue, 02 Jan 2024 00:00:00 GMT" + ) -> FetchResponse { + FetchResponse( + feedData: feedData, + lastModified: lastModified, + etag: etag, + wasModified: feedData != nil + ) + } + + // MARK: - Not Modified Metadata Tests + + @Test("Not modified metadata preserves feed data") + internal func testNotModifiedPreservesFeedData() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed( + title: "Original Title", + description: "Original Description", + minUpdateInterval: 3_600 + ) + let response = createFetchResponse( + feedData: nil, // 304 response has no feed data + etag: "updated-etag", + lastModified: "Thu, 04 Jan 2024 00:00:00 GMT" + ) + + let metadata = builder.buildNotModifiedMetadata( + feed: feed, + response: response, + totalAttempts: 11 + ) + + // Feed data should be preserved + #expect(metadata.title == "Original Title") + #expect(metadata.description == "Original Description") + #expect(metadata.minUpdateInterval == 3_600) + + // HTTP headers updated from response + #expect(metadata.etag == "updated-etag") + #expect(metadata.lastModified == "Thu, 04 Jan 2024 00:00:00 GMT") + } + + @Test("Not modified metadata updates HTTP headers if provided") + internal func testNotModifiedUpdatesHTTPHeaders() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed( + etag: "old-etag", + lastModified: "Old-Date" + ) + let response = createFetchResponse( + feedData: nil, + etag: "new-etag", + lastModified: "New-Date" + ) + + let metadata = builder.buildNotModifiedMetadata( + feed: feed, + response: response, + totalAttempts: 11 + ) + + #expect(metadata.etag == "new-etag") + #expect(metadata.lastModified == "New-Date") + } + + @Test("Not modified metadata keeps existing headers if none provided") + internal func testNotModifiedKeepsExistingHeadersIfNoneProvided() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed( + etag: "existing-etag", + lastModified: "existing-date" + ) + let response = createFetchResponse( + feedData: nil, + etag: nil, + lastModified: nil + ) + + let metadata = builder.buildNotModifiedMetadata( + feed: feed, + response: response, + totalAttempts: 11 + ) + + #expect(metadata.etag == "existing-etag") + #expect(metadata.lastModified == "existing-date") + } + + @Test("Not modified counts as successful attempt") + internal func testNotModifiedCountsAsSuccess() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed( + successfulAttempts: 10, + failureCount: 3 + ) + let response = createFetchResponse(feedData: nil) + + let metadata = builder.buildNotModifiedMetadata( + feed: feed, + response: response, + totalAttempts: 14 + ) + + #expect(metadata.successfulAttempts == 11) // 10 + 1 + #expect(metadata.failureCount == 0) // Reset on success + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Success.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Success.swift new file mode 100644 index 00000000..749e1ca8 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Success.swift @@ -0,0 +1,164 @@ +// +// FeedMetadataBuilder+Success.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import CelestraKit +import Foundation +import Testing + +@testable import CelestraCloudKit + +extension FeedMetadataBuilder { + @Suite("Success Metadata Tests") + internal struct SuccessTests { + // MARK: - Test Fixtures + + private func createFeed( + title: String = "Original Title", + description: String? = "Original Description", + etag: String? = "original-etag", + lastModified: String? = "Mon, 01 Jan 2024 00:00:00 GMT", + minUpdateInterval: TimeInterval? = 3_600, + totalAttempts: Int64 = 10, + successfulAttempts: Int64 = 8, + failureCount: Int64 = 2 + ) -> Feed { + Feed( + recordName: "feed-123", + feedURL: "https://example.com/feed.xml", + title: title, + description: description, + totalAttempts: totalAttempts, + successfulAttempts: successfulAttempts, + etag: etag, + lastModified: lastModified, + failureCount: failureCount, + minUpdateInterval: minUpdateInterval + ) + } + + private func createFeedData( + title: String = "New Feed Title", + description: String? = "New Feed Description", + minUpdateInterval: TimeInterval? = 7_200 + ) -> FeedData { + FeedData( + title: title, + description: description, + items: [], // Not used in metadata building + minUpdateInterval: minUpdateInterval + ) + } + + private func createFetchResponse( + feedData: FeedData? = nil, + etag: String? = "new-etag", + lastModified: String? = "Tue, 02 Jan 2024 00:00:00 GMT" + ) -> FetchResponse { + FetchResponse( + feedData: feedData, + lastModified: lastModified, + etag: etag, + wasModified: feedData != nil + ) + } + + // MARK: - Success Metadata Tests + + @Test("Success metadata uses new feed data") + internal func testSuccessMetadataUsesNewData() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed() + let feedData = createFeedData( + title: "Updated Title", + description: "Updated Description", + minUpdateInterval: 7_200 + ) + let response = createFetchResponse( + feedData: feedData, + etag: "new-etag-123", + lastModified: "Wed, 03 Jan 2024 12:00:00 GMT" + ) + + let metadata = builder.buildSuccessMetadata( + feedData: feedData, + response: response, + feed: feed, + totalAttempts: 11 + ) + + // New feed data should override + #expect(metadata.title == "Updated Title") + #expect(metadata.description == "Updated Description") + #expect(metadata.minUpdateInterval == 7_200) + + // HTTP headers from response + #expect(metadata.etag == "new-etag-123") + #expect(metadata.lastModified == "Wed, 03 Jan 2024 12:00:00 GMT") + + // Counters + #expect(metadata.totalAttempts == 11) + #expect(metadata.successfulAttempts == 9) // 8 + 1 + #expect(metadata.failureCount == 0) // Reset on success + } + + @Test("Success metadata increments successful attempts") + internal func testSuccessIncrementsSuccessfulAttempts() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed(successfulAttempts: 5) + let feedData = createFeedData() + let response = createFetchResponse(feedData: feedData) + + let metadata = builder.buildSuccessMetadata( + feedData: feedData, + response: response, + feed: feed, + totalAttempts: 11 + ) + + #expect(metadata.successfulAttempts == 6) // 5 + 1 + } + + @Test("Success metadata resets failure count") + internal func testSuccessResetsFailureCount() { + let builder = CelestraCloudKit.FeedMetadataBuilder() + let feed = createFeed(failureCount: 5) + let feedData = createFeedData() + let response = createFetchResponse(feedData: feedData) + + let metadata = builder.buildSuccessMetadata( + feedData: feedData, + response: response, + feed: feed, + totalAttempts: 11 + ) + + #expect(metadata.failureCount == 0) // Always reset on success + } + } +} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder.swift new file mode 100644 index 00000000..b0bbf0d9 --- /dev/null +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder.swift @@ -0,0 +1,2 @@ +/// Namespace for FeedMetadataBuilder tests +internal enum FeedMetadataBuilder {} diff --git a/Examples/CelestraCloud/project.yml b/Examples/CelestraCloud/project.yml new file mode 100644 index 00000000..7780079b --- /dev/null +++ b/Examples/CelestraCloud/project.yml @@ -0,0 +1,13 @@ +name: CelestraCloud +settings: + LINT_MODE: ${LINT_MODE} +packages: + CelestraCloud: + path: . +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {} diff --git a/Examples/CelestraCloud/schema.ckdb b/Examples/CelestraCloud/schema.ckdb new file mode 100644 index 00000000..e8e3d572 --- /dev/null +++ b/Examples/CelestraCloud/schema.ckdb @@ -0,0 +1,116 @@ +DEFINE SCHEMA + +// Feed - RSS feed metadata shared across all users in public database +RECORD TYPE Feed ( + // CloudKit system fields + "___recordID" REFERENCE QUERYABLE, + + // Core feed metadata + "feedURL" STRING QUERYABLE SORTABLE, // Unique RSS/Atom feed URL + "title" STRING SEARCHABLE SORTABLE, // Feed title + "description" STRING, // Feed description/subtitle + "category" STRING QUERYABLE, // Content category (e.g. "Technology", "News") + "imageURL" STRING, // Feed logo/icon URL + "siteURL" STRING, // Website home page URL + "language" STRING QUERYABLE, // ISO language code (e.g. "en", "es") + "tags" LIST, // User-defined tags for categorization + + // Quality & verification indicators + "isFeatured" INT64 QUERYABLE, // 1 if featured feed, 0 otherwise + "isVerified" INT64 QUERYABLE, // 1 if verified/trusted source, 0 otherwise + "qualityScore" INT64 QUERYABLE SORTABLE, // CALCULATED: 0-100 score based on reliability (40%) + popularity (30%) + update consistency (20%) + verification (10%) + "subscriberCount" INT64 QUERYABLE SORTABLE, // Number of subscribers (from external system) + + // Timestamps (NOTE: Use CloudKit's built-in createdTimestamp for creation time) + "verifiedTimestamp" TIMESTAMP QUERYABLE SORTABLE, // Last time feed URL was verified + "attemptedTimestamp" TIMESTAMP QUERYABLE SORTABLE, // Last fetch attempt timestamp + + // Calculated feed characteristics + "updateFrequency" DOUBLE, // CALCULATED: Average articles per day (articlesPublished / daysSinceFirstArticle) + "minUpdateInterval" DOUBLE, // CALCULATED: Minimum hours between requests (respects RSS tag, default 1.0) + + // Server-side fetch metrics + "totalAttempts" INT64, // Total fetch attempts counter + "successfulAttempts" INT64, // Successful fetch counter + "failureCount" INT64, // Consecutive failure count (reset on success) + "lastFailureReason" STRING, // Most recent error message (detailed errors logged locally) + "isActive" INT64 QUERYABLE, // 1 if feed is active, 0 if disabled due to persistent failures + + // HTTP caching headers (for conditional requests) + "etag" STRING, // Last ETag from server for 304 Not Modified support + "lastModified" STRING, // Last-Modified header value + + // Permissions: Public read, users and server can add feeds, server maintains metadata + // _world: All users can read feed catalog (public database) + // _creator: Authenticated users can create new feeds (iOS app) + // _icloud: Server-to-server auth can create and modify feeds (CLI/backend) + // Note: Users cannot modify feeds after creation - server manages all metadata updates + GRANT READ TO "_world", + GRANT CREATE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT WRITE TO "_icloud" +); + +// Article - RSS article content shared across all users in public database +RECORD TYPE Article ( + // CloudKit system fields + "___recordID" REFERENCE QUERYABLE, + + // Article identity & relationships + "feedRecordName" STRING QUERYABLE SORTABLE, // Parent Feed recordName (foreign key) + "guid" STRING QUERYABLE SORTABLE, // Article unique ID from RSS (unique per feed) + + // Core article content + "title" STRING SEARCHABLE, // Article title + "excerpt" STRING, // Summary/description text + "content" STRING SEARCHABLE, // Full HTML content + "contentText" STRING SEARCHABLE, // CALCULATED: Plain text extracted from HTML (stripHTML) + "author" STRING QUERYABLE, // Article author name + "url" STRING, // Article permalink URL + "imageURL" STRING, // Article hero/featured image URL (manually enriched) + + // Publishing metadata + "publishedTimestamp" TIMESTAMP QUERYABLE SORTABLE, // When article was originally published + "fetchedTimestamp" TIMESTAMP QUERYABLE SORTABLE, // When article was fetched from RSS feed + "expiresTimestamp" TIMESTAMP QUERYABLE SORTABLE, // CALCULATED: Cache expiration (fetchedTimestamp + ttlDays * 24h) + + // Deduplication & content analysis + "contentHash" STRING QUERYABLE, // CALCULATED: SHA256 composite key (title|url|guid) for change detection + "wordCount" INT64, // CALCULATED: Word count from contentText (split by whitespace) + "estimatedReadingTime" INT64, // CALCULATED: Reading time in minutes (wordCount / 200 words per minute) + + // Enrichment fields (manually set or ML-detected) + "language" STRING QUERYABLE, // ISO language code (manually enriched or ML-detected) + "tags" LIST, // Content tags/keywords (manually enriched) + + // Permissions: Public read, users and server can create articles, server maintains + // _world: All users can read article content (public database) + // _creator: Authenticated users can create articles when adding new feeds (iOS app) + // _icloud: Server-to-server auth can create and modify articles (CLI/backend) + // Note: iOS app creates articles on first feed add for immediate display + // Server handles all subsequent updates via scheduled jobs + GRANT READ TO "_world", + GRANT CREATE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT WRITE TO "_icloud" +); + +// FeedSubscription - Anonymous subscription tracking for popularity metrics (public database) +RECORD TYPE FeedSubscription ( + // CloudKit system fields + // NOTE: recordName is SHA256("\(feedRecordName)-\(userRecordID)") for privacy + // Same user + same feed = same record ID (deduplication across devices) + // Hash includes feedRecordName so subscriptions can't be correlated across feeds + // One-way hash prevents reversing to identify users + "___recordID" REFERENCE QUERYABLE, + + // Subscription relationship + "feedRecordName" STRING QUERYABLE SORTABLE, // References Feed recordName in public database + + // Permissions: Public database with hashed record IDs for anonymity + // _world: All users can read subscription records (for count queries) + // _creator: Users can create/delete their own hashed subscription records + // Server periodically queries subscription counts to update Feed.subscriberCount + GRANT READ TO "_world", + GRANT CREATE, WRITE TO "_creator" +); diff --git a/Package.resolved b/Package.resolved index a789e793..91a02e89 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5772f4a3fc82aa266a22f0fba10c8e939829aaad63cfdfce1504d281aca64e03", + "originHash" : "f522a83bf637ef80939be380e3541af820ec621a68e0d1d84ced8f8f198c36c5", "pins" : [ { "identity" : "swift-asn1", @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "6fac6f7c428d5feea2639b5f5c8b06ddfb79434b", - "version" : "1.1.0" + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" } } ], diff --git a/Package.swift b/Package.swift index f85f69c6..ff492fcd 100644 --- a/Package.swift +++ b/Package.swift @@ -97,7 +97,7 @@ let package = Package( dependencies: [ // Swift OpenAPI Runtime dependencies .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.0"), - .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.2.0"), // Crypto library for cross-platform cryptographic operations .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), // Logging library for cross-platform logging diff --git a/Scripts/update-subrepo.sh b/Scripts/update-subrepo.sh new file mode 100755 index 00000000..2023c370 --- /dev/null +++ b/Scripts/update-subrepo.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -e + +# Generic script to update any example subrepo +# Usage: ./Scripts/update-subrepo.sh Examples/BushelCloud +# ./Scripts/update-subrepo.sh Examples/Celestra + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "Example: $0 Examples/BushelCloud" + exit 1 +fi + +SUBREPO_PATH="$1" +SUBREPO_NAME=$(basename "$SUBREPO_PATH") + +if [ ! -d "$SUBREPO_PATH" ]; then + echo "❌ Error: Directory $SUBREPO_PATH does not exist" + exit 1 +fi + +if [ ! -f "$SUBREPO_PATH/.gitrepo" ]; then + echo "❌ Error: $SUBREPO_PATH is not a git subrepo (missing .gitrepo file)" + exit 1 +fi + +echo "🔄 Updating $SUBREPO_NAME subrepo..." +echo "" + +# Extract current branch from .gitrepo +CURRENT_BRANCH=$(grep -E '^\s*branch\s*=' "$SUBREPO_PATH/.gitrepo" | sed 's/.*=\s*//') +echo "📍 Current branch: $CURRENT_BRANCH" + +# Pull latest from subrepo +echo "" +echo "📥 Pulling latest from remote..." +git subrepo pull "$SUBREPO_PATH" --branch="$CURRENT_BRANCH" + +# Handle local MistKit dependencies (for BushelCloud and CelestraCloud) +echo "" +echo "🔄 Checking for local MistKit dependencies..." +if grep -q '\.package(name: "MistKit", path:' "$SUBREPO_PATH/Package.swift"; then + echo "✓ Found local MistKit dependency - preserving for local development" +else + echo "✓ No local MistKit dependency found" +fi + +# Resolve dependencies +echo "" +echo "📦 Resolving Swift package dependencies..." +cd "$SUBREPO_PATH" +swift package resolve + +# Build to verify +echo "" +echo "🔨 Building to verify changes..." +swift build + +# Go back to project root +cd - > /dev/null + +echo "" +echo "✅ Update complete!" +echo "" +echo "📊 Subrepo status:" +cat "$SUBREPO_PATH/.gitrepo" | grep -E "commit|branch|remote" + +echo "" +echo "🎯 Next steps:" +echo " 1. Review changes: git diff $SUBREPO_PATH/" +echo " 2. Run tests: cd $SUBREPO_PATH && swift test" +echo " 3. Commit changes: git add $SUBREPO_PATH && git commit -m 'Update $SUBREPO_NAME subrepo'"