Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add swiftlint and swiftformat with CI, fix quoted CLI output

- Add .swiftlint.yml and .swiftformat with reasonable defaults
- Add CI workflow running lint + format check + tests on push/PR
- Run swiftlint --fix and swiftformat across entire codebase
- Fix modifier order (nonisolated before private)
- Fix debugPrint → print in all CLI commands (debugPrint wraps
output in quotes, which is wrong for user-facing CLI output)

+501 -350
+32
.github/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + 9 + jobs: 10 + lint: 11 + runs-on: macos-15 12 + steps: 13 + - uses: actions/checkout@v6 14 + 15 + - name: SwiftLint 16 + run: swiftlint lint --quiet 17 + 18 + - name: SwiftFormat check 19 + run: | 20 + swiftformat --lint . 2>&1 21 + if [ $? -ne 0 ]; then 22 + echo "::error::Code is not formatted. Run 'swiftformat .' locally." 23 + exit 1 24 + fi 25 + 26 + test: 27 + runs-on: macos-15 28 + steps: 29 + - uses: actions/checkout@v6 30 + 31 + - name: Run tests 32 + run: swift test
+29
.swiftformat
··· 1 + # SwiftFormat configuration for Attic 2 + 3 + --swiftversion 6.1 4 + --minversion 0.55 5 + 6 + # Rules 7 + --enable sortImports 8 + --enable redundantSelf 9 + --enable trailingCommas 10 + --enable blankLinesBetweenScopes 11 + --enable consecutiveBlankLines 12 + 13 + # Formatting options 14 + --indent 4 15 + --indentcase false 16 + --trimwhitespace always 17 + --voidtype void 18 + --wraparguments before-first 19 + --wrapparameters before-first 20 + --wrapcollections before-first 21 + --maxwidth 120 22 + --commas always 23 + --semicolons never 24 + --stripunusedargs closure-only 25 + --self remove 26 + --header strip 27 + 28 + # Exclusions 29 + --exclude .build,Package.swift
+54
.swiftlint.yml
··· 1 + # SwiftLint configuration for Attic 2 + 3 + excluded: 4 + - .build 5 + - Package.swift 6 + 7 + disabled_rules: 8 + - trailing_comma # We prefer trailing commas in multiline collections 9 + - todo # TODOs are fine during development 10 + - class_delegate_protocol # Delegate protocols here don't need to be class-only 11 + - large_tuple # Used in Dependencies.makeBackupDeps return type 12 + - for_where # False positives on TaskGroup drain loops with side effects 13 + 14 + opt_in_rules: 15 + - empty_count 16 + - closure_spacing 17 + - contains_over_first_not_nil 18 + - direct_return 19 + - empty_string 20 + - first_where 21 + - last_where 22 + - modifier_order 23 + - redundant_type_annotation 24 + - sorted_imports 25 + - vertical_whitespace_closing_braces 26 + 27 + identifier_name: 28 + min_length: 1 29 + excluded: 30 + - id 31 + 32 + line_length: 33 + warning: 120 34 + error: 160 35 + 36 + file_length: 37 + warning: 500 38 + error: 600 39 + 40 + type_body_length: 41 + warning: 400 42 + error: 500 43 + 44 + function_body_length: 45 + warning: 80 46 + error: 150 47 + 48 + function_parameter_count: 49 + warning: 8 50 + error: 12 51 + 52 + cyclomatic_complexity: 53 + warning: 15 54 + error: 25
+1 -1
Sources/AtticCLI/AtticCLI.swift
··· 15 15 RefreshMetadataCommand.self, 16 16 RebuildCommand.self, 17 17 InitCommand.self, 18 - ] 18 + ], 19 19 ) 20 20 }
+22 -13
Sources/AtticCLI/BackupCommand.swift
··· 1 1 import ArgumentParser 2 - import Foundation 3 2 import AtticCore 3 + import Foundation 4 4 import LadderKit 5 5 6 6 struct BackupCommand: AsyncParsableCommand { 7 7 static let configuration = CommandConfiguration( 8 8 commandName: "backup", 9 - abstract: "Back up photos and videos to S3." 9 + abstract: "Back up photos and videos to S3.", 10 10 ) 11 11 12 12 @Option(name: .long, help: "Number of assets per export batch.") ··· 60 60 batchSize: batchSize, 61 61 limit: limit, 62 62 type: assetKind, 63 - dryRun: dryRun 63 + dryRun: dryRun, 64 64 ) 65 65 66 66 // Prevent idle sleep during backup (released automatically via deinit) ··· 75 75 s3: s3, 76 76 options: options, 77 77 progress: progress, 78 - networkMonitor: networkMonitor 78 + networkMonitor: networkMonitor, 79 79 ) 80 80 81 81 _ = powerAssertion // prevent unused warning, released in deinit 82 82 83 83 if !isTTY { 84 - debugPrint("Backup complete: \(report.uploaded) uploaded, \(report.failed) failed (\(formatBytes(report.totalBytes)))") 84 + let summary = "Backup complete: \(report.uploaded) uploaded, " 85 + + "\(report.failed) failed (\(formatBytes(report.totalBytes)))" 86 + print(summary) 85 87 } 86 88 } 87 89 } ··· 89 91 /// Simple line-by-line progress for non-TTY output (CI, pipes). 90 92 struct LogProgressDelegate: BackupProgressDelegate { 91 93 func backupStarted(pending: Int, photos: Int, videos: Int) { 92 - debugPrint("Starting backup: \(pending) assets (\(photos) photos, \(videos) videos)") 94 + print("Starting backup: \(pending) assets (\(photos) photos, \(videos) videos)") 93 95 } 96 + 94 97 func batchStarted(batchNumber: Int, totalBatches: Int, assetCount: Int) { 95 - debugPrint("Batch \(batchNumber)/\(totalBatches) (\(assetCount) assets)") 98 + print("Batch \(batchNumber)/\(totalBatches) (\(assetCount) assets)") 96 99 } 100 + 97 101 func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) { 98 - debugPrint(" ✓ \(filename) (\(formatBytes(size)))") 102 + print(" ✓ \(filename) (\(formatBytes(size)))") 99 103 } 104 + 100 105 func assetFailed(uuid: String, filename: String, message: String) { 101 - debugPrint(" ✗ \(filename): \(message)") 106 + print(" ✗ \(filename): \(message)") 102 107 } 108 + 103 109 func manifestSaved(entriesCount: Int) { 104 - debugPrint(" Manifest saved (\(entriesCount) entries)") 110 + print(" Manifest saved (\(entriesCount) entries)") 105 111 } 112 + 106 113 func backupPaused(reason: String) { 107 - debugPrint(" ⏸ Paused: \(reason)") 114 + print(" ⏸ Paused: \(reason)") 108 115 } 116 + 109 117 func backupResumed() { 110 - debugPrint(" ▶ Resumed") 118 + print(" ▶ Resumed") 111 119 } 120 + 112 121 func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) { 113 - debugPrint("Done: \(uploaded) uploaded, \(failed) failed (\(formatBytes(totalBytes)))") 122 + print("Done: \(uploaded) uploaded, \(failed) failed (\(formatBytes(totalBytes)))") 114 123 } 115 124 }
+3 -3
Sources/AtticCLI/Dependencies.swift
··· 1 - import Foundation 2 1 import AtticCore 2 + import Foundation 3 3 import LadderKit 4 4 5 5 /// Builds the real dependencies for CLI commands from config + keychain. ··· 18 18 let keychain = SecurityKeychain() 19 19 return try keychain.loadCredentials( 20 20 accessKeyService: config.keychain.accessKeyService, 21 - secretKeyService: config.keychain.secretKeyService 21 + secretKeyService: config.keychain.secretKeyService, 22 22 ) 23 23 } 24 24 ··· 29 29 bucket: config.bucket, 30 30 endpoint: config.endpoint, 31 31 region: config.region, 32 - pathStyle: config.pathStyle 32 + pathStyle: config.pathStyle, 33 33 ) 34 34 } 35 35
+17 -17
Sources/AtticCLI/InitCommand.swift
··· 1 1 import ArgumentParser 2 - import Foundation 3 2 import AtticCore 3 + import Foundation 4 4 5 5 struct InitCommand: AsyncParsableCommand { 6 6 static let configuration = CommandConfiguration( 7 7 commandName: "init", 8 - abstract: "Set up Attic with S3 endpoint, bucket, and credentials." 8 + abstract: "Set up Attic with S3 endpoint, bucket, and credentials.", 9 9 ) 10 10 11 11 func run() async throws { 12 - debugPrint("Attic Setup") 13 - debugPrint("===========") 14 - debugPrint("") 12 + print("Attic Setup") 13 + print("===========") 14 + print("") 15 15 16 16 let endpoint = prompt("S3 endpoint (https://...): ") 17 17 guard endpoint.hasPrefix("https://") else { ··· 30 30 endpoint: endpoint, 31 31 region: region, 32 32 bucket: bucket, 33 - pathStyle: pathStyle 33 + pathStyle: pathStyle, 34 34 ) 35 35 // Save config 36 36 let configProvider = FileConfigProvider() ··· 41 41 try keychain.store(service: config.keychain.accessKeyService, value: accessKey) 42 42 try keychain.store(service: config.keychain.secretKeyService, value: secretKey) 43 43 44 - debugPrint("") 45 - debugPrint("Configuration saved.") 46 - debugPrint("Credentials stored in macOS Keychain.") 47 - debugPrint("") 48 - debugPrint("Next steps:") 49 - debugPrint(" attic scan Scan your Photos library") 50 - debugPrint(" attic status Check backup progress") 51 - debugPrint(" attic backup Start backing up") 52 - debugPrint("") 53 - debugPrint("macOS will ask for permission to access Photos and") 54 - debugPrint("Keychain on first run — both are expected and required.") 44 + print("") 45 + print("Configuration saved.") 46 + print("Credentials stored in macOS Keychain.") 47 + print("") 48 + print("Next steps:") 49 + print(" attic scan Scan your Photos library") 50 + print(" attic status Check backup progress") 51 + print(" attic backup Start backing up") 52 + print("") 53 + print("macOS will ask for permission to access Photos and") 54 + print("Keychain on first run — both are expected and required.") 55 55 } 56 56 } 57 57
+3 -3
Sources/AtticCLI/LadderKitExportProvider.swift
··· 1 - import Foundation 2 1 import AtticCore 2 + import Foundation 3 3 import LadderKit 4 4 5 5 /// Bridges LadderKit's PhotoExporter to AtticCore's ExportProviding protocol. ··· 7 7 private let exporter: PhotoExporter 8 8 9 9 init(stagingDir: URL, library: PhotoLibrary = PhotoKitLibrary()) { 10 - self.exporter = PhotoExporter( 10 + exporter = PhotoExporter( 11 11 stagingDir: stagingDir, 12 12 library: library, 13 - scriptExporter: AppleScriptRunner() 13 + scriptExporter: AppleScriptRunner(), 14 14 ) 15 15 } 16 16
+13 -13
Sources/AtticCLI/RebuildCommand.swift
··· 4 4 struct RebuildCommand: AsyncParsableCommand { 5 5 static let configuration = CommandConfiguration( 6 6 commandName: "rebuild", 7 - abstract: "Rebuild manifest from S3 metadata files (disaster recovery)." 7 + abstract: "Rebuild manifest from S3 metadata files (disaster recovery).", 8 8 ) 9 9 10 10 func run() async throws { 11 11 let (_, s3, manifestStore) = try Dependencies.makeBackupDeps() 12 12 13 - debugPrint("Rebuilding manifest from S3 metadata...") 13 + print("Rebuilding manifest from S3 metadata...") 14 14 15 15 let (manifest, report) = try await runRebuildManifest( 16 - s3: s3, manifestStore: manifestStore 16 + s3: s3, manifestStore: manifestStore, 17 17 ) 18 18 19 - debugPrint("") 20 - debugPrint("Rebuild Results") 21 - debugPrint("===============") 22 - debugPrint("Recovered: \(report.recovered)") 23 - debugPrint("Skipped: \(report.skipped)") 24 - debugPrint("Errors: \(report.errors.count)") 25 - debugPrint("") 26 - debugPrint("Manifest saved with \(manifest.entries.count) entries.") 19 + print("") 20 + print("Rebuild Results") 21 + print("===============") 22 + print("Recovered: \(report.recovered)") 23 + print("Skipped: \(report.skipped)") 24 + print("Errors: \(report.errors.count)") 25 + print("") 26 + print("Manifest saved with \(manifest.entries.count) entries.") 27 27 28 28 if !report.errors.isEmpty { 29 - debugPrint("") 29 + print("") 30 30 for err in report.errors.prefix(10) { 31 - debugPrint(" ✗ \(err.key): \(err.message)") 31 + print(" ✗ \(err.key): \(err.message)") 32 32 } 33 33 } 34 34 }
+13 -13
Sources/AtticCLI/RefreshMetadataCommand.swift
··· 4 4 struct RefreshMetadataCommand: AsyncParsableCommand { 5 5 static let configuration = CommandConfiguration( 6 6 commandName: "refresh-metadata", 7 - abstract: "Re-generate and upload metadata JSON for all backed-up assets." 7 + abstract: "Re-generate and upload metadata JSON for all backed-up assets.", 8 8 ) 9 9 10 10 @Option(name: .long, help: "Number of concurrent uploads.") ··· 21 21 let options = RefreshMetadataOptions(concurrency: concurrency, dryRun: dryRun) 22 22 23 23 if dryRun { 24 - let backedUpCount = assets.filter { manifest.isBackedUp($0.uuid) }.count 25 - debugPrint("Dry run: would refresh metadata for \(backedUpCount) assets.") 24 + let backedUpCount = assets.count(where: { manifest.isBackedUp($0.uuid) }) 25 + print("Dry run: would refresh metadata for \(backedUpCount) assets.") 26 26 return 27 27 } 28 28 29 - debugPrint("Refreshing metadata for backed-up assets...") 29 + print("Refreshing metadata for backed-up assets...") 30 30 31 31 let report = try await runRefreshMetadata( 32 - assets: assets, manifest: manifest, s3: s3, options: options 32 + assets: assets, manifest: manifest, s3: s3, options: options, 33 33 ) 34 34 35 - debugPrint("") 36 - debugPrint("Refresh Results") 37 - debugPrint("===============") 38 - debugPrint("Updated: \(report.updated)") 39 - debugPrint("Failed: \(report.failed)") 40 - debugPrint("Bytes: \(formatBytes(report.totalBytes))") 35 + print("") 36 + print("Refresh Results") 37 + print("===============") 38 + print("Updated: \(report.updated)") 39 + print("Failed: \(report.failed)") 40 + print("Bytes: \(formatBytes(report.totalBytes))") 41 41 42 42 if !report.errors.isEmpty { 43 - debugPrint("") 43 + print("") 44 44 for err in report.errors.prefix(10) { 45 - debugPrint(" ✗ \(err.uuid): \(err.message)") 45 + print(" ✗ \(err.uuid): \(err.message)") 46 46 } 47 47 } 48 48 }
+13 -13
Sources/AtticCLI/ScanCommand.swift
··· 5 5 struct ScanCommand: AsyncParsableCommand { 6 6 static let configuration = CommandConfiguration( 7 7 commandName: "scan", 8 - abstract: "Scan your Photos library and show a summary." 8 + abstract: "Scan your Photos library and show a summary.", 9 9 ) 10 10 11 11 func run() async throws { 12 12 let assets = Dependencies.loadAssets() 13 13 14 14 if assets.isEmpty { 15 - debugPrint("No assets found in Photos library.") 15 + print("No assets found in Photos library.") 16 16 return 17 17 } 18 18 19 19 let photos = assets.filter { $0.kind == .photo } 20 20 let videos = assets.filter { $0.kind == .video } 21 21 22 - debugPrint("Photos Library Scan") 23 - debugPrint("===================") 24 - debugPrint("Total assets: \(assets.count)") 25 - debugPrint(" Photos: \(photos.count)") 26 - debugPrint(" Videos: \(videos.count)") 22 + print("Photos Library Scan") 23 + print("===================") 24 + print("Total assets: \(assets.count)") 25 + print(" Photos: \(photos.count)") 26 + print(" Videos: \(videos.count)") 27 27 28 28 // Group by UTI 29 29 var utiCounts: [String: Int] = [:] ··· 33 33 } 34 34 let topUTIs = utiCounts.sorted { $0.value > $1.value }.prefix(10) 35 35 36 - debugPrint("") 37 - debugPrint("Top file types:") 36 + print("") 37 + print("Top file types:") 38 38 for (uti, count) in topUTIs { 39 - debugPrint(" \(uti): \(count)") 39 + print(" \(uti): \(count)") 40 40 } 41 41 42 42 // Favorites + edited 43 43 let favorites = assets.filter(\.isFavorite).count 44 44 let edited = assets.filter(\.hasEdit).count 45 - debugPrint("") 46 - debugPrint("Favorites: \(favorites)") 47 - debugPrint("Edited: \(edited)") 45 + print("") 46 + print("Favorites: \(favorites)") 47 + print("Edited: \(edited)") 48 48 } 49 49 }
+15 -15
Sources/AtticCLI/StatusCommand.swift
··· 5 5 struct StatusCommand: AsyncParsableCommand { 6 6 static let configuration = CommandConfiguration( 7 7 commandName: "status", 8 - abstract: "Show backup progress — how many assets are backed up vs pending." 8 + abstract: "Show backup progress — how many assets are backed up vs pending.", 9 9 ) 10 10 11 11 func run() async throws { ··· 29 29 30 30 let pct = assets.isEmpty ? 100.0 : Double(backedUpCount) / Double(assets.count) * 100 31 31 32 - debugPrint("Attic Backup Status") 33 - debugPrint("====================") 34 - debugPrint("Bucket: \(config.bucket)") 35 - debugPrint("Completion: \(String(format: "%.1f", pct))%") 36 - debugPrint("") 37 - debugPrint("Backed up: \(backedUpCount) (\(formatBytes(backedUpBytes)))") 38 - debugPrint(" Photos: \(backedUpPhotos)") 39 - debugPrint(" Videos: \(backedUpVideos)") 40 - debugPrint("") 41 - debugPrint("Pending: \(pendingCount)") 42 - debugPrint(" Photos: \(pendingPhotos)") 43 - debugPrint(" Videos: \(pendingVideos)") 44 - debugPrint("") 45 - debugPrint("Manifest: \(manifest.entries.count) entries") 32 + print("Attic Backup Status") 33 + print("====================") 34 + print("Bucket: \(config.bucket)") 35 + print("Completion: \(String(format: "%.1f", pct))%") 36 + print("") 37 + print("Backed up: \(backedUpCount) (\(formatBytes(backedUpBytes)))") 38 + print(" Photos: \(backedUpPhotos)") 39 + print(" Videos: \(backedUpVideos)") 40 + print("") 41 + print("Pending: \(pendingCount)") 42 + print(" Photos: \(pendingPhotos)") 43 + print(" Videos: \(pendingVideos)") 44 + print("") 45 + print("Manifest: \(manifest.entries.count) entries") 46 46 } 47 47 }
+3 -5
Sources/AtticCLI/TerminalRenderer.swift
··· 1 - import Foundation 2 1 import AtticCore 2 + import Foundation 3 3 import LadderKit 4 4 5 5 /// ANSI live-updating dashboard for backup progress. ··· 62 62 state.uploaded += 1 63 63 state.totalBytes += size 64 64 state.currentFile = "\(filename) (\(formatBytes(size)))" 65 - if type == .photo { state.uploadedPhotos += 1 } 66 - else { state.uploadedVideos += 1 } 65 + if type == .photo { state.uploadedPhotos += 1 } else { state.uploadedVideos += 1 } 67 66 } 68 67 render() 69 68 } ··· 110 109 } 111 110 renderFinal() 112 111 } 113 - 114 112 115 113 // MARK: - Rendering 116 114 ··· 183 181 // Clear the live display 184 182 let lineCount = 8 185 183 print("\u{1b}[\(lineCount)A", terminator: "") 186 - for _ in 0..<lineCount { 184 + for _ in 0 ..< lineCount { 187 185 print("\u{1b}[2K") 188 186 } 189 187 print("\u{1b}[\(lineCount)A", terminator: "")
+13 -13
Sources/AtticCLI/VerifyCommand.swift
··· 4 4 struct VerifyCommand: AsyncParsableCommand { 5 5 static let configuration = CommandConfiguration( 6 6 commandName: "verify", 7 - abstract: "Verify backed-up assets exist in S3." 7 + abstract: "Verify backed-up assets exist in S3.", 8 8 ) 9 9 10 10 @Option(name: .long, help: "Number of concurrent verification requests.") ··· 15 15 let manifest = try await Dependencies.loadManifest(store: manifestStore) 16 16 17 17 guard !manifest.entries.isEmpty else { 18 - debugPrint("Manifest is empty — nothing to verify.") 18 + print("Manifest is empty — nothing to verify.") 19 19 return 20 20 } 21 21 22 - debugPrint("Verifying \(manifest.entries.count) assets...") 22 + print("Verifying \(manifest.entries.count) assets...") 23 23 24 24 let report = try await runVerify(manifest: manifest, s3: s3, concurrency: concurrency) 25 25 26 - debugPrint("") 27 - debugPrint("Verify Results") 28 - debugPrint("==============") 29 - debugPrint("OK: \(report.ok)") 30 - debugPrint("Missing: \(report.missing)") 31 - debugPrint("Errors: \(report.failed)") 26 + print("") 27 + print("Verify Results") 28 + print("==============") 29 + print("OK: \(report.ok)") 30 + print("Missing: \(report.missing)") 31 + print("Errors: \(report.failed)") 32 32 33 33 if !report.errors.isEmpty { 34 - debugPrint("") 35 - debugPrint("Issues:") 34 + print("") 35 + print("Issues:") 36 36 for err in report.errors.prefix(20) { 37 - debugPrint(" \(err.uuid): \(err.message)") 37 + print(" \(err.uuid): \(err.message)") 38 38 } 39 39 if report.errors.count > 20 { 40 - debugPrint(" ... and \(report.errors.count - 20) more") 40 + print(" ... and \(report.errors.count - 20) more") 41 41 } 42 42 } 43 43 }
+17 -18
Sources/AtticCore/AtticConfig.swift
··· 14 14 15 15 public init( 16 16 accessKeyService: String = "attic-s3-access-key", 17 - secretKeyService: String = "attic-s3-secret-key" 17 + secretKeyService: String = "attic-s3-secret-key", 18 18 ) { 19 19 self.accessKeyService = accessKeyService 20 20 self.secretKeyService = secretKeyService ··· 26 26 region: String, 27 27 bucket: String, 28 28 pathStyle: Bool = true, 29 - keychain: KeychainConfig = KeychainConfig() 29 + keychain: KeychainConfig = KeychainConfig(), 30 30 ) { 31 31 self.endpoint = endpoint 32 32 self.region = region ··· 80 80 try fm.createDirectory(at: directory, withIntermediateDirectories: true) 81 81 try fm.setAttributes( 82 82 [.posixPermissions: 0o700], 83 - ofItemAtPath: directory.path 83 + ofItemAtPath: directory.path, 84 84 ) 85 85 } 86 86 ··· 99 99 try fm.moveItem(at: tempPath, to: path) 100 100 try fm.setAttributes( 101 101 [.posixPermissions: 0o600], 102 - ofItemAtPath: path.path 102 + ofItemAtPath: path.path, 103 103 ) 104 104 } 105 105 } 106 106 107 107 // MARK: - Validation 108 108 109 - private nonisolated(unsafe) let bucketPattern = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/ 109 + nonisolated(unsafe) private let bucketPattern = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/ 110 110 111 111 extension AtticConfig { 112 112 /// Validate a raw JSON object into an AtticConfig. ··· 117 117 118 118 guard let endpoint = obj["endpoint"] as? String, !endpoint.isEmpty else { 119 119 throw ConfigError.invalid( 120 - #"Config: "endpoint" is required (e.g. "https://s3.fr-par.scw.cloud")"# 120 + #"Config: "endpoint" is required (e.g. "https://s3.fr-par.scw.cloud")"#, 121 121 ) 122 122 } 123 123 guard endpoint.hasPrefix("https://") else { 124 124 throw ConfigError.invalid( 125 - #"Config: "endpoint" must start with https://"# 125 + #"Config: "endpoint" must start with https://"#, 126 126 ) 127 127 } 128 128 129 129 guard let region = obj["region"] as? String, !region.isEmpty else { 130 130 throw ConfigError.invalid( 131 - #"Config: "region" is required (e.g. "fr-par")"# 131 + #"Config: "region" is required (e.g. "fr-par")"#, 132 132 ) 133 133 } 134 134 ··· 138 138 guard bucket.wholeMatch(of: bucketPattern) != nil else { 139 139 throw ConfigError.invalid( 140 140 "Config: \"bucket\" name \"\(bucket)\" is invalid. " 141 - + "Use lowercase letters, numbers, dots, and hyphens (3-63 chars)." 141 + + "Use lowercase letters, numbers, dots, and hyphens (3-63 chars).", 142 142 ) 143 143 } 144 144 145 - let pathStyle: Bool 146 - if let ps = obj["pathStyle"] { 147 - pathStyle = (ps as? Bool) ?? true 145 + let pathStyle: Bool = if let ps = obj["pathStyle"] { 146 + (ps as? Bool) ?? true 148 147 } else { 149 - pathStyle = true 148 + true 150 149 } 151 150 152 151 let keychainObj = obj["keychain"] as? [String: Any] ?? [:] ··· 162 161 pathStyle: pathStyle, 163 162 keychain: KeychainConfig( 164 163 accessKeyService: accessKeyService, 165 - secretKeyService: secretKeyService 166 - ) 164 + secretKeyService: secretKeyService, 165 + ), 167 166 ) 168 167 } 169 168 } ··· 175 174 176 175 public var description: String { 177 176 switch self { 178 - case .notFound(let path): 177 + case let .notFound(path): 179 178 "No config file found at \(path)\n" 180 - + "Run \"attic init\" to set up your S3 connection, or create the file manually." 181 - case .invalid(let message): 179 + + "Run \"attic init\" to set up your S3 connection, or create the file manually." 180 + case let .invalid(message): 182 181 message 183 182 } 184 183 }
+23 -24
Sources/AtticCore/BackupPipeline.swift
··· 29 29 type: AssetKind? = nil, 30 30 dryRun: Bool = false, 31 31 saveInterval: Int = 50, 32 - networkTimeout: Duration = .seconds(900) 32 + networkTimeout: Duration = .seconds(900), 33 33 ) { 34 34 self.batchSize = batchSize 35 35 self.limit = limit ··· 64 64 s3: any S3Providing, 65 65 options: BackupOptions = BackupOptions(), 66 66 progress: any BackupProgressDelegate = NullProgressDelegate(), 67 - networkMonitor: (any NetworkMonitoring)? = nil 67 + networkMonitor: (any NetworkMonitoring)? = nil, 68 68 ) async throws -> BackupReport { 69 69 // Filter to pending assets, optionally by type 70 70 var pending = assets.filter { asset in ··· 85 85 var photoCount = 0 86 86 var videoCount = 0 87 87 for asset in pending { 88 - if asset.kind == .photo { photoCount += 1 } 89 - else { videoCount += 1 } 88 + if asset.kind == .photo { photoCount += 1 } else { videoCount += 1 } 90 89 } 91 90 92 91 progress.backupStarted(pending: pending.count, photos: photoCount, videos: videoCount) ··· 107 106 // Process in batches 108 107 let totalBatches = (pending.count + options.batchSize - 1) / options.batchSize 109 108 110 - for batchIndex in 0..<totalBatches { 109 + for batchIndex in 0 ..< totalBatches { 111 110 try Task.checkCancellation() 112 111 113 112 let start = batchIndex * options.batchSize 114 113 let end = min(start + options.batchSize, pending.count) 115 - let batch = Array(pending[start..<end]) 114 + let batch = Array(pending[start ..< end]) 116 115 let batchUUIDs = batch.map(\.uuid) 117 116 118 117 progress.batchStarted( 119 118 batchNumber: batchIndex + 1, 120 119 totalBatches: totalBatches, 121 - assetCount: batch.count 120 + assetCount: batch.count, 122 121 ) 123 122 124 123 // 1. Export via LadderKit ··· 153 152 saveInterval: options.saveInterval, 154 153 progress: progress, 155 154 networkMonitor: networkMonitor, 156 - networkTimeout: options.networkTimeout 155 + networkTimeout: options.networkTimeout, 157 156 ) 158 157 continue 159 158 } catch let error as ExportProviderError where error.isPermission { ··· 188 187 saveInterval: options.saveInterval, 189 188 progress: progress, 190 189 networkMonitor: networkMonitor, 191 - networkTimeout: options.networkTimeout 190 + networkTimeout: options.networkTimeout, 192 191 ) 193 192 } 194 193 ··· 205 204 saveInterval: options.saveInterval, 206 205 progress: progress, 207 206 networkMonitor: networkMonitor, 208 - networkTimeout: options.networkTimeout 207 + networkTimeout: options.networkTimeout, 209 208 ) 210 209 } catch { 211 210 let msg = String(describing: error) ··· 226 225 progress.backupCompleted( 227 226 uploaded: report.uploaded, 228 227 failed: report.failed, 229 - totalBytes: report.totalBytes 228 + totalBytes: report.totalBytes, 230 229 ) 231 230 232 231 return report ··· 261 260 saveInterval: Int, 262 261 progress: any BackupProgressDelegate, 263 262 networkMonitor: (any NetworkMonitoring)? = nil, 264 - networkTimeout: Duration = .seconds(900) 263 + networkTimeout: Duration = .seconds(900), 265 264 ) async throws { 266 265 // Record export errors 267 266 for err in batchResult.errors { ··· 279 278 280 279 let ext = S3Paths.extensionFromUTIOrFilename( 281 280 uti: asset.uniformTypeIdentifier, 282 - filename: asset.originalFilename ?? "unknown" 281 + filename: asset.originalFilename ?? "unknown", 283 282 ) 284 283 let s3Key = try S3Paths.originalKey( 285 284 uuid: asset.uuid, 286 285 dateCreated: asset.creationDate, 287 - extension: ext 286 + extension: ext, 288 287 ) 289 288 290 289 do { ··· 294 293 manifest: &manifest, report: &report, 295 294 sinceLastSave: &sinceLastSave, 296 295 saveInterval: saveInterval, 297 - manifestStore: manifestStore, progress: progress 296 + manifestStore: manifestStore, progress: progress, 298 297 ) 299 298 } catch is CancellationError { 300 299 throw CancellationError() ··· 312 311 313 312 progress.backupPaused(reason: "Waiting for network...") 314 313 let recovered = try await monitor.waitForNetwork( 315 - timeout: networkTimeout 314 + timeout: networkTimeout, 316 315 ) 317 316 progress.backupResumed() 318 317 ··· 325 324 manifest: &manifest, report: &report, 326 325 sinceLastSave: &sinceLastSave, 327 326 saveInterval: saveInterval, 328 - manifestStore: manifestStore, progress: progress 327 + manifestStore: manifestStore, progress: progress, 329 328 ) 330 329 try? FileManager.default.removeItem(atPath: exported.path) 331 330 continue ··· 337 336 let timeoutMinutes = Int(networkTimeout.components.seconds) / 60 338 337 report.appendError( 339 338 uuid: exported.uuid, 340 - message: "Network unavailable for \(timeoutMinutes) minutes, backup paused" 339 + message: "Network unavailable for \(timeoutMinutes) minutes, backup paused", 341 340 ) 342 341 report.failed += 1 343 342 if sinceLastSave > 0 { ··· 373 372 sinceLastSave: inout Int, 374 373 saveInterval: Int, 375 374 manifestStore: any ManifestStoring, 376 - progress: any BackupProgressDelegate 375 + progress: any BackupProgressDelegate, 377 376 ) async throws { 378 377 // Upload original via file URL (avoids loading into memory) 379 378 let fileURL = URL(fileURLWithPath: exported.path) ··· 381 380 try await s3.putObject( 382 381 key: s3Key, 383 382 fileURL: fileURL, 384 - contentType: contentTypeForExtension(ext) 383 + contentType: contentTypeForExtension(ext), 385 384 ) 386 385 } 387 386 ··· 391 390 asset: asset, 392 391 s3Key: s3Key, 393 392 checksum: "sha256:\(exported.sha256)", 394 - backedUpAt: isoNow 393 + backedUpAt: isoNow, 395 394 ) 396 395 let metaData = try metadataEncoder.encode(meta) 397 396 let metaKey = try S3Paths.metadataKey(uuid: asset.uuid) ··· 399 398 try await s3.putObject( 400 399 key: metaKey, 401 400 body: metaData, 402 - contentType: "application/json" 401 + contentType: "application/json", 403 402 ) 404 403 } 405 404 ··· 408 407 uuid: asset.uuid, 409 408 s3Key: s3Key, 410 409 checksum: "sha256:\(exported.sha256)", 411 - size: Int(exported.size) 410 + size: Int(exported.size), 412 411 ) 413 412 sinceLastSave += 1 414 413 report.uploaded += 1 ··· 419 418 uuid: asset.uuid, 420 419 filename: filename, 421 420 type: asset.kind, 422 - size: Int(exported.size) 421 + size: Int(exported.size), 423 422 ) 424 423 425 424 // Periodic manifest save
+2 -2
Sources/AtticCore/ExportProviding.swift
··· 21 21 22 22 public var description: String { 23 23 switch self { 24 - case .timeout(let seconds): 24 + case let .timeout(seconds): 25 25 "Export timed out after \(seconds)s" 26 - case .permissionDenied(let message): 26 + case let .permissionDenied(message): 27 27 message 28 28 } 29 29 }
+8 -8
Sources/AtticCore/KeychainCredentials.swift
··· 16 16 public protocol KeychainProviding: Sendable { 17 17 func loadCredentials( 18 18 accessKeyService: String, 19 - secretKeyService: String 19 + secretKeyService: String, 20 20 ) throws -> S3Credentials 21 21 22 22 func store(service: String, value: String) throws ··· 30 30 31 31 public func loadCredentials( 32 32 accessKeyService: String = "attic-s3-access-key", 33 - secretKeyService: String = "attic-s3-secret-key" 33 + secretKeyService: String = "attic-s3-secret-key", 34 34 ) throws -> S3Credentials { 35 35 let accessKeyId = try get(service: accessKeyService) 36 36 let secretAccessKey = try get(service: secretKeyService) 37 37 return S3Credentials( 38 38 accessKeyId: accessKeyId, 39 - secretAccessKey: secretAccessKey 39 + secretAccessKey: secretAccessKey, 40 40 ) 41 41 } 42 42 ··· 58 58 guard status == errSecSuccess else { 59 59 throw KeychainError.storeFailed( 60 60 service: service, 61 - status: status 61 + status: status, 62 62 ) 63 63 } 64 64 } ··· 79 79 else { 80 80 throw KeychainError.readFailed( 81 81 service: service, 82 - status: status 82 + status: status, 83 83 ) 84 84 } 85 85 ··· 94 94 95 95 public var description: String { 96 96 switch self { 97 - case .readFailed(let service, let status): 97 + case let .readFailed(service, status): 98 98 "Failed to read keychain item \"\(service)\" (status: \(status)). " 99 - + "Store it with: security add-generic-password -s \(service) -a attic -w \"<value>\"" 100 - case .storeFailed(let service, let status): 99 + + "Store it with: security add-generic-password -s \(service) -a attic -w \"<value>\"" 100 + case let .storeFailed(service, status): 101 101 "Failed to store credential in Keychain for service \"\(service)\" (status: \(status))" 102 102 } 103 103 }
+7 -8
Sources/AtticCore/Manifest.swift
··· 13 13 s3Key: String, 14 14 checksum: String, 15 15 backedUpAt: String, 16 - size: Int? = nil 16 + size: Int? = nil, 17 17 ) { 18 18 self.uuid = uuid 19 19 self.s3Key = s3Key ··· 42 42 s3Key: String, 43 43 checksum: String, 44 44 size: Int? = nil, 45 - backedUpAt: String? = nil 45 + backedUpAt: String? = nil, 46 46 ) { 47 47 entries[uuid] = ManifestEntry( 48 48 uuid: uuid, 49 49 s3Key: s3Key, 50 50 checksum: checksum, 51 51 backedUpAt: backedUpAt ?? isoFormatter.string(from: Date()), 52 - size: size 52 + size: size, 53 53 ) 54 54 } 55 55 } ··· 65 65 66 66 // MARK: - Validation 67 67 68 - extension Manifest { 68 + public extension Manifest { 69 69 /// Parse and validate manifest data. 70 - public static func parse(from data: Data) throws -> Manifest { 71 - let decoded = try JSONDecoder().decode(Manifest.self, from: data) 72 - return decoded 70 + static func parse(from data: Data) throws -> Manifest { 71 + try JSONDecoder().decode(Manifest.self, from: data) 73 72 } 74 73 75 74 /// Encode manifest to JSON data. 76 75 /// 77 76 /// - Parameter sortedKeys: Use sorted keys for human readability (slower for large manifests). 78 77 /// Defaults to false for periodic saves; callers should pass true for final saves. 79 - public func encoded(sortedKeys: Bool = false) throws -> Data { 78 + func encoded(sortedKeys: Bool = false) throws -> Data { 80 79 let encoder = JSONEncoder() 81 80 encoder.outputFormatting = sortedKeys ? [.prettyPrinted, .sortedKeys] : [] 82 81 var data = try encoder.encode(self)
+2 -2
Sources/AtticCore/MetadataBuilder.swift
··· 53 53 asset: AssetInfo, 54 54 s3Key: String, 55 55 checksum: String, 56 - backedUpAt: String 56 + backedUpAt: String, 57 57 ) -> AssetMetadata { 58 58 AssetMetadata( 59 59 uuid: asset.uuid, ··· 76 76 editor: asset.editor, 77 77 s3Key: s3Key, 78 78 checksum: checksum, 79 - backedUpAt: backedUpAt 79 + backedUpAt: backedUpAt, 80 80 ) 81 81 }
+2 -2
Sources/AtticCore/MockS3Provider.swift
··· 46 46 guard let obj = objects[key] else { return nil } 47 47 return S3ObjectMeta( 48 48 contentLength: obj.body.count, 49 - contentType: obj.contentType 49 + contentType: obj.contentType, 50 50 ) 51 51 } 52 52 ··· 57 57 .map { key in 58 58 S3ListObject( 59 59 key: key, 60 - size: objects[key]?.body.count ?? 0 60 + size: objects[key]?.body.count ?? 0, 61 61 ) 62 62 } 63 63 }
+1 -1
Sources/AtticCore/NWPathNetworkMonitor.swift
··· 19 19 monitor = NWPathMonitor() 20 20 monitor.pathUpdateHandler = { [weak self] path in 21 21 guard let self else { return } 22 - self.lock.withLock { 22 + lock.withLock { 23 23 self.currentStatus = path.status 24 24 } 25 25 }
+1 -1
Sources/AtticCore/PowerAssertion.swift
··· 14 14 public init(reason: String) { 15 15 activity = ProcessInfo.processInfo.beginActivity( 16 16 options: [.userInitiated, .idleSystemSleepDisabled], 17 - reason: reason 17 + reason: reason, 18 18 ) 19 19 } 20 20
+4 -3
Sources/AtticCore/RebuildManifest.swift
··· 14 14 /// is lost or corrupted. 15 15 public func runRebuildManifest( 16 16 s3: any S3Providing, 17 - manifestStore: any ManifestStoring 17 + manifestStore: any ManifestStoring, 18 18 ) async throws -> (Manifest, RebuildManifestReport) { 19 19 let objects = try await s3.listObjects(prefix: "metadata/assets/") 20 20 var manifest = Manifest() ··· 32 32 33 33 guard S3Paths.isValidUUID(parsed.uuid), 34 34 S3Paths.isValidS3Key(parsed.s3Key), 35 - isValidChecksum(parsed.checksum) else { 35 + isValidChecksum(parsed.checksum) 36 + else { 36 37 report.errors.append((key: obj.key, message: "Validation failed")) 37 38 continue 38 39 } ··· 41 42 uuid: parsed.uuid, 42 43 s3Key: parsed.s3Key, 43 44 checksum: parsed.checksum, 44 - backedUpAt: parsed.backedUpAt ?? isoFormatter.string(from: Date()) 45 + backedUpAt: parsed.backedUpAt ?? isoFormatter.string(from: Date()), 45 46 ) 46 47 report.recovered += 1 47 48 } catch {
+24 -10
Sources/AtticCore/RefreshMetadata.swift
··· 47 47 manifest: Manifest, 48 48 s3: any S3Providing, 49 49 options: RefreshMetadataOptions = RefreshMetadataOptions(), 50 - progress: any RefreshMetadataProgressDelegate = NullRefreshMetadataProgressDelegate() 50 + progress: any RefreshMetadataProgressDelegate = NullRefreshMetadataProgressDelegate(), 51 51 ) async throws -> RefreshMetadataReport { 52 52 // Only refresh assets that are in the manifest 53 53 let backedUp = assets.filter { manifest.isBackedUp($0.uuid) } ··· 72 72 await withTaskGroup(of: Void.self) { group in 73 73 var cursor = 0 74 74 75 - for _ in 0..<min(options.concurrency, backedUp.count) { 75 + for _ in 0 ..< min(options.concurrency, backedUp.count) { 76 76 let asset = backedUp[cursor] 77 77 cursor += 1 78 78 group.addTask { 79 - await refreshSingle(asset: asset, manifest: manifest, s3: s3, 80 - report: report, progress: progress) 79 + await refreshSingle( 80 + asset: asset, 81 + manifest: manifest, 82 + s3: s3, 83 + report: report, 84 + progress: progress, 85 + ) 81 86 } 82 87 } 83 88 ··· 86 91 let asset = backedUp[cursor] 87 92 cursor += 1 88 93 group.addTask { 89 - await refreshSingle(asset: asset, manifest: manifest, s3: s3, 90 - report: report, progress: progress) 94 + await refreshSingle( 95 + asset: asset, 96 + manifest: manifest, 97 + s3: s3, 98 + report: report, 99 + progress: progress, 100 + ) 91 101 } 92 102 } 93 103 } ··· 106 116 var totalBytes = 0 107 117 var errors: [(uuid: String, message: String)] = [] 108 118 109 - func markUpdated(bytes: Int) { updated += 1; totalBytes += bytes } 119 + func markUpdated(bytes: Int) { 120 + updated += 1 121 + totalBytes += bytes 122 + } 123 + 110 124 func markFailed(_ uuid: String, _ message: String) { 111 125 failed += 1 112 126 if errors.count < maxReportErrors { ··· 117 131 func snapshot() -> RefreshMetadataReport { 118 132 RefreshMetadataReport( 119 133 updated: updated, skipped: 0, failed: failed, 120 - totalBytes: totalBytes, errors: errors 134 + totalBytes: totalBytes, errors: errors, 121 135 ) 122 136 } 123 137 } ··· 127 141 manifest: Manifest, 128 142 s3: any S3Providing, 129 143 report: RefreshReport, 130 - progress: any RefreshMetadataProgressDelegate 144 + progress: any RefreshMetadataProgressDelegate, 131 145 ) async { 132 146 guard let entry = manifest.entries[asset.uuid] else { return } 133 147 ··· 136 150 asset: asset, 137 151 s3Key: entry.s3Key, 138 152 checksum: entry.checksum, 139 - backedUpAt: entry.backedUpAt 153 + backedUpAt: entry.backedUpAt, 140 154 ) 141 155 let data = try metadataEncoder.encode(meta) 142 156 let metaKey = try S3Paths.metadataKey(uuid: asset.uuid)
+2 -2
Sources/AtticCore/RetryPolicy.swift
··· 7 7 public func withRetry<T: Sendable>( 8 8 maxAttempts: Int = 3, 9 9 baseDelay: Duration = .seconds(1), 10 - operation: @Sendable () async throws -> T 10 + operation: @Sendable () async throws -> T, 11 11 ) async throws -> T { 12 - for attempt in 1...maxAttempts { 12 + for attempt in 1 ... maxAttempts { 13 13 do { 14 14 return try await operation() 15 15 } catch {
+2 -2
Sources/AtticCore/S3ManifestStore.swift
··· 41 41 /// 4. If neither exists, return empty manifest. 42 42 public func loadManifestWithMigration( 43 43 s3Store: ManifestStoring, 44 - localDirectory: URL? = nil 44 + localDirectory: URL? = nil, 45 45 ) async throws -> Manifest { 46 46 let s3Manifest = try await s3Store.load() 47 47 ··· 80 80 switch s3Error { 81 81 case .httpError(404, _): 82 82 return true 83 - case .s3Error(let code, _): 83 + case let .s3Error(code, _): 84 84 return code == "NoSuchKey" || code == "NotFound" 85 85 default: 86 86 break
+7 -7
Sources/AtticCore/S3Paths.swift
··· 4 4 public enum S3Paths { 5 5 // MARK: - Validation patterns 6 6 7 - private static nonisolated(unsafe) let uuidPattern = /^[A-Za-z0-9._\-]+$/ 8 - private static nonisolated(unsafe) let s3KeyPattern = /^[A-Za-z0-9\/._\-]+$/ 9 - private static nonisolated(unsafe) let extPattern = /^[a-z0-9]+$/ 7 + nonisolated(unsafe) private static let uuidPattern = /^[A-Za-z0-9._\-]+$/ 8 + nonisolated(unsafe) private static let s3KeyPattern = /^[A-Za-z0-9\/._\-]+$/ 9 + nonisolated(unsafe) private static let extPattern = /^[a-z0-9]+$/ 10 10 11 11 /// UTI-to-extension lookup table. 12 12 private static let utiMap: [String: String] = [ ··· 35 35 public static func originalKey( 36 36 uuid: String, 37 37 dateCreated: Date?, 38 - extension ext: String 38 + extension ext: String, 39 39 ) throws -> String { 40 40 try assertSafeUUID(uuid) 41 41 let cleanExt = ext.lowercased().trimmingPrefix(".") ··· 65 65 /// Extract file extension from a UTI or filename. 66 66 public static func extensionFromUTIOrFilename( 67 67 uti: String?, 68 - filename: String 68 + filename: String, 69 69 ) -> String { 70 70 if let uti, let mapped = utiMap[uti] { 71 71 return mapped ··· 113 113 114 114 public var description: String { 115 115 switch self { 116 - case .unsafeUUID(let value): 116 + case let .unsafeUUID(value): 117 117 "Unsafe UUID for S3 key: \(value)" 118 - case .unsafeExtension(let value): 118 + case let .unsafeExtension(value): 119 119 "Unsafe extension for S3 key: \(value)" 120 120 } 121 121 }
+4 -4
Sources/AtticCore/S3Providing.swift
··· 40 40 func listObjects(prefix: String) async throws -> [S3ListObject] 41 41 } 42 42 43 - // Convenience overloads. 44 - extension S3Providing { 45 - public func putObject(key: String, body: Data) async throws { 43 + /// Convenience overloads. 44 + public extension S3Providing { 45 + func putObject(key: String, body: Data) async throws { 46 46 try await putObject(key: key, body: body, contentType: nil) 47 47 } 48 48 49 49 /// Default file upload: uses memory-mapped I/O. Real implementations may override. 50 - public func putObject(key: String, fileURL: URL, contentType: String?) async throws { 50 + func putObject(key: String, fileURL: URL, contentType: String?) async throws { 51 51 let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) 52 52 try await putObject(key: key, body: data, contentType: contentType) 53 53 }
+4 -4
Sources/AtticCore/S3XMLParsing.swift
··· 39 39 func parser( 40 40 _ parser: XMLParser, didStartElement elementName: String, 41 41 namespaceURI: String?, qualifiedName: String?, 42 - attributes: [String: String] = [:] 42 + attributes: [String: String] = [:], 43 43 ) { 44 44 currentElement = elementName 45 45 currentText = "" ··· 56 56 57 57 func parser( 58 58 _ parser: XMLParser, didEndElement elementName: String, 59 - namespaceURI: String?, qualifiedName: String? 59 + namespaceURI: String?, qualifiedName: String?, 60 60 ) { 61 61 let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines) 62 62 ··· 92 92 func parser( 93 93 _ parser: XMLParser, didStartElement elementName: String, 94 94 namespaceURI: String?, qualifiedName: String?, 95 - attributes: [String: String] = [:] 95 + attributes: [String: String] = [:], 96 96 ) { 97 97 currentElement = elementName 98 98 currentText = "" ··· 104 104 105 105 func parser( 106 106 _ parser: XMLParser, didEndElement elementName: String, 107 - namespaceURI: String?, qualifiedName: String? 107 + namespaceURI: String?, qualifiedName: String?, 108 108 ) { 109 109 let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines) 110 110 switch elementName {
+12 -11
Sources/AtticCore/URLSessionS3Client.swift
··· 1 - import Foundation 2 1 import AWSSigner 2 + import Foundation 3 3 import NIOHTTP1 4 4 5 5 /// S3 client using URLSession and aws-signer-v4. ··· 20 20 bucket: String, 21 21 endpoint: String, 22 22 region: String, 23 - pathStyle: Bool 23 + pathStyle: Bool, 24 24 ) throws { 25 25 guard let endpointURL = URL(string: endpoint) else { 26 26 throw S3ClientError.unexpectedResponse("Invalid endpoint URL: \(endpoint)") ··· 32 32 33 33 let creds = StaticCredential( 34 34 accessKeyId: credentials.accessKeyId, 35 - secretAccessKey: credentials.secretAccessKey 35 + secretAccessKey: credentials.secretAccessKey, 36 36 ) 37 - self.signer = AWSSigner(credentials: creds, name: "s3", region: region) 37 + signer = AWSSigner(credentials: creds, name: "s3", region: region) 38 38 39 39 let config = URLSessionConfiguration.default 40 40 config.timeoutIntervalForRequest = 30 41 41 config.timeoutIntervalForResource = 600 42 - self.session = URLSession(configuration: config) 42 + session = URLSession(configuration: config) 43 43 } 44 44 45 45 // MARK: - S3Providing ··· 157 157 let bucketHost = "\(scheme)://\(bucket).\(host)\(port)" 158 158 guard let baseURL = URL(string: bucketHost) else { 159 159 throw S3ClientError.unexpectedResponse( 160 - "Invalid virtual-hosted URL: \(bucketHost)") 160 + "Invalid virtual-hosted URL: \(bucketHost)", 161 + ) 161 162 } 162 163 if key.isEmpty { 163 164 url = baseURL ··· 198 199 method: method, 199 200 headers: nioHeaders, 200 201 body: body, 201 - date: Date() 202 + date: Date(), 202 203 ) 203 204 204 205 // Apply signed headers back to the URLRequest ··· 212 213 throw S3ClientError.unexpectedResponse("Not an HTTP response") 213 214 } 214 215 215 - guard http.statusCode >= 200 && http.statusCode < 300 else { 216 + guard http.statusCode >= 200, http.statusCode < 300 else { 216 217 if let s3Error = parseS3Error(data: data) { 217 218 throw S3ClientError.s3Error(code: s3Error.code, message: s3Error.message) 218 219 } ··· 229 230 230 231 public var description: String { 231 232 switch self { 232 - case .httpError(let status, let key): 233 + case let .httpError(status, key): 233 234 "S3 HTTP \(status) for key: \(key)" 234 - case .unexpectedResponse(let msg): 235 + case let .unexpectedResponse(msg): 235 236 "Unexpected response: \(msg)" 236 - case .s3Error(let code, let message): 237 + case let .s3Error(code, message): 237 238 "S3 error \(code): \(message)" 238 239 } 239 240 }
+8 -4
Sources/AtticCore/VerifyPipeline.swift
··· 12 12 public func runVerify( 13 13 manifest: Manifest, 14 14 s3: any S3Providing, 15 - concurrency: Int = 20 15 + concurrency: Int = 20, 16 16 ) async throws -> VerifyReport { 17 17 let entries = Array(manifest.entries.values) 18 18 ··· 26 26 var cursor = 0 27 27 28 28 // Seed initial tasks up to concurrency limit 29 - for _ in 0..<min(concurrency, entries.count) { 29 + for _ in 0 ..< min(concurrency, entries.count) { 30 30 let entry = entries[cursor] 31 31 cursor += 1 32 32 group.addTask { ··· 58 58 var failed = 0 59 59 var errors: [(uuid: String, message: String)] = [] 60 60 61 - func markOK() { ok += 1 } 61 + func markOK() { 62 + ok += 1 63 + } 64 + 62 65 func markMissing(_ uuid: String) { 63 66 missing += 1 64 67 if errors.count < maxReportErrors { 65 68 errors.append((uuid: uuid, message: "Missing from S3")) 66 69 } 67 70 } 71 + 68 72 func markFailed(_ uuid: String, _ message: String) { 69 73 failed += 1 70 74 if errors.count < maxReportErrors { ··· 80 84 private func verifySingle( 81 85 entry: ManifestEntry, 82 86 s3: any S3Providing, 83 - report: VerifyReportAccumulator 87 + report: VerifyReportAccumulator, 84 88 ) async { 85 89 do { 86 90 if try await s3.headObject(key: entry.s3Key) != nil {
+3 -4
Tests/AtticCoreTests/AtticConfigTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 1 3 import Testing 2 - import Foundation 3 - @testable import AtticCore 4 4 5 - @Suite("AtticConfig") 6 5 struct AtticConfigTests { 7 6 @Test func validateAcceptsValidConfigWithAllFields() throws { 8 7 let config = try AtticConfig.validate([ ··· 98 97 let config = AtticConfig( 99 98 endpoint: "https://s3.fr-par.scw.cloud", 100 99 region: "fr-par", 101 - bucket: "test-bucket" 100 + bucket: "test-bucket", 102 101 ) 103 102 104 103 try provider.write(config)
+20 -21
Tests/AtticCoreTests/BackupPipelineTests.swift
··· 1 - import Testing 1 + @testable import AtticCore 2 2 import Foundation 3 3 import LadderKit 4 - @testable import AtticCore 4 + import Testing 5 5 6 6 // MARK: - Test helpers 7 7 ··· 13 13 14 14 init( 15 15 assets: [String: (filename: String, data: Data)] = [:], 16 - stagingDir: URL? = nil 16 + stagingDir: URL? = nil, 17 17 ) { 18 - self.availableAssets = assets 18 + availableAssets = assets 19 19 self.stagingDir = stagingDir ?? FileManager.default.temporaryDirectory 20 20 .appendingPathComponent("attic-test-staging-\(UUID().uuidString)") 21 21 } ··· 37 37 uuid: uuid, 38 38 path: filePath.path, 39 39 size: Int64(asset.data.count), 40 - sha256: "fakehash_\(uuid)" 40 + sha256: "fakehash_\(uuid)", 41 41 )) 42 42 } else { 43 43 errors.append(LadderKit.ExportError( 44 44 uuid: uuid, 45 - message: "Asset not found in mock library" 45 + message: "Asset not found in mock library", 46 46 )) 47 47 } 48 48 } ··· 71 71 func exportBatch(uuids: [String]) async throws -> ExportResponse { 72 72 let containsSlow = uuids.contains { slowUUIDs.contains($0) } 73 73 74 - if containsSlow && uuids.count > 1 { 74 + if containsSlow, uuids.count > 1 { 75 75 throw ExportProviderError.timeout(seconds: 300) 76 76 } 77 77 ··· 92 92 uuid: String, 93 93 kind: AssetKind = .photo, 94 94 filename: String = "IMG_0001.HEIC", 95 - uti: String = "public.heic" 95 + uti: String = "public.heic", 96 96 ) -> AssetInfo { 97 97 AssetInfo( 98 98 identifier: "\(uuid)/L0/001", ··· 105 105 isFavorite: false, 106 106 originalFilename: filename, 107 107 uniformTypeIdentifier: uti, 108 - hasEdit: false 108 + hasEdit: false, 109 109 ) 110 110 } 111 111 ··· 117 117 118 118 // MARK: - Tests 119 119 120 - @Suite("BackupPipeline") 121 120 struct BackupPipelineTests { 122 121 @Test func uploadsPendingAssetsAndUpdatesManifest() async throws { 123 122 let assets = [makeTestAsset(uuid: "uuid-1"), makeTestAsset(uuid: "uuid-2")] ··· 135 134 manifestStore: manifestStore, 136 135 exporter: exporter, 137 136 s3: s3, 138 - options: BackupOptions(batchSize: 10) 137 + options: BackupOptions(batchSize: 10), 139 138 ) 140 139 141 140 #expect(report.uploaded == 2) ··· 163 162 uuid: "uuid-1", 164 163 s3Key: "originals/2024/01/uuid-1.heic", 165 164 checksum: "sha256:abc", 166 - backedUpAt: "2024-01-15T00:00:00Z" 165 + backedUpAt: "2024-01-15T00:00:00Z", 167 166 ) 168 167 169 168 let report = try await runBackup( ··· 172 171 manifestStore: manifestStore, 173 172 exporter: exporter, 174 173 s3: s3, 175 - options: BackupOptions(batchSize: 10) 174 + options: BackupOptions(batchSize: 10), 176 175 ) 177 176 178 177 #expect(report.uploaded == 1) ··· 198 197 manifestStore: manifestStore, 199 198 exporter: exporter, 200 199 s3: s3, 201 - options: BackupOptions(batchSize: 10, limit: 1) 200 + options: BackupOptions(batchSize: 10, limit: 1), 202 201 ) 203 202 204 203 #expect(report.uploaded == 1) ··· 217 216 manifestStore: manifestStore, 218 217 exporter: exporter, 219 218 s3: s3, 220 - options: BackupOptions(dryRun: true) 219 + options: BackupOptions(dryRun: true), 221 220 ) 222 221 223 222 #expect(report.uploaded == 0) ··· 245 244 manifestStore: manifestStore, 246 245 exporter: exporter, 247 246 s3: s3, 248 - options: BackupOptions(batchSize: 10) 247 + options: BackupOptions(batchSize: 10), 249 248 ) 250 249 251 250 #expect(report.uploaded == 1) ··· 261 260 uuid: "video-1", 262 261 kind: .video, 263 262 filename: "VID.MOV", 264 - uti: "com.apple.quicktime-movie" 263 + uti: "com.apple.quicktime-movie", 265 264 ), 266 265 ] 267 266 ··· 277 276 manifestStore: manifestStore, 278 277 exporter: exporter, 279 278 s3: s3, 280 - options: BackupOptions(batchSize: 10, type: .video) 279 + options: BackupOptions(batchSize: 10, type: .video), 281 280 ) 282 281 283 282 #expect(report.uploaded == 1) ··· 300 299 let exporter = TimeoutExportProvider( 301 300 inner: inner, 302 301 slowUUIDs: ["slow-1"], 303 - deferredRetrySucceeds: true 302 + deferredRetrySucceeds: true, 304 303 ) 305 304 let (s3, manifestStore) = try await createTestContext() 306 305 var manifest = try await manifestStore.load() ··· 311 310 manifestStore: manifestStore, 312 311 exporter: exporter, 313 312 s3: s3, 314 - options: BackupOptions(batchSize: 3) 313 + options: BackupOptions(batchSize: 3), 315 314 ) 316 315 317 316 #expect(report.uploaded == 3) ··· 336 335 manifestStore: manifestStore, 337 336 exporter: exporter, 338 337 s3: s3, 339 - options: BackupOptions(batchSize: 10) 338 + options: BackupOptions(batchSize: 10), 340 339 ) 341 340 342 341 // Load from S3 — should persist
+1 -2
Tests/AtticCoreTests/FormatBytesTests.swift
··· 1 - import Testing 2 1 @testable import AtticCore 2 + import Testing 3 3 4 - @Suite("FormatBytes") 5 4 struct FormatBytesTests { 6 5 @Test func zero() { 7 6 #expect(formatBytes(0) == "0 B")
+9 -10
Tests/AtticCoreTests/ManifestCompatibilityTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 1 3 import Testing 2 - import Foundation 3 - @testable import AtticCore 4 4 5 - @Suite("Manifest cross-version compatibility") 6 5 struct ManifestCompatibilityTests { 7 6 /// A manifest JSON produced by the Deno CLI (v0.2.x format). 8 7 /// The Swift version must be able to parse this exactly. ··· 37 36 #expect(entry1?.s3Key == "originals/2024/01/A1B2C3D4-E5F6-7890-ABCD-EF1234567890.heic") 38 37 #expect(entry1?.checksum == "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08") 39 38 #expect(entry1?.backedUpAt == "2024-01-15T12:34:56Z") 40 - #expect(entry1?.size == 4194304) 39 + #expect(entry1?.size == 4_194_304) 41 40 42 41 let entry2 = manifest.entries["DEADBEEF-CAFE-4321-BABE-FEEDFACE1234"] 43 42 #expect(entry2 != nil) ··· 51 50 s3Key: "originals/2024/06/uuid-1.heic", 52 51 checksum: "sha256:abc123def456", 53 52 size: 1024, 54 - backedUpAt: "2024-06-15T10:00:00Z" 53 + backedUpAt: "2024-06-15T10:00:00Z", 55 54 ) 56 55 57 56 // Encode and re-parse 58 57 let data = try manifest.encoded() 59 - let parsed = try JSONSerialization.jsonObject(with: data) as! [String: Any] 58 + let parsed = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) 60 59 61 60 // Verify structure matches Deno format 62 - let entries = parsed["entries"] as! [String: Any] 63 - let entry = entries["uuid-1"] as! [String: Any] 61 + let entries = try #require(parsed["entries"] as? [String: Any]) 62 + let entry = try #require(entries["uuid-1"] as? [String: Any]) 64 63 65 64 #expect(entry["uuid"] as? String == "uuid-1") 66 65 #expect(entry["s3Key"] as? String == "originals/2024/06/uuid-1.heic") ··· 83 82 s3Key: "originals/2025/03/NEW-UUID.png", 84 83 checksum: "sha256:newchecksum", 85 84 size: 2048, 86 - backedUpAt: "2025-03-21T00:00:00Z" 85 + backedUpAt: "2025-03-21T00:00:00Z", 87 86 ) 88 87 89 88 // Round-trip ··· 97 96 98 97 // Original entries preserved exactly 99 98 let original = reloaded.entries["A1B2C3D4-E5F6-7890-ABCD-EF1234567890"] 100 - #expect(original?.size == 4194304) 99 + #expect(original?.size == 4_194_304) 101 100 #expect(original?.backedUpAt == "2024-01-15T12:34:56Z") 102 101 } 103 102
+10 -11
Tests/AtticCoreTests/ManifestTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 1 3 import Testing 2 - import Foundation 3 - @testable import AtticCore 4 4 5 - @Suite("Manifest") 6 5 struct ManifestTests { 7 6 @Test func emptyManifest() { 8 7 let manifest = Manifest() ··· 10 9 #expect(!manifest.isBackedUp("any-uuid")) 11 10 } 12 11 13 - @Test func markBackedUp() { 12 + @Test func markBackedUp() throws { 14 13 var manifest = Manifest() 15 14 manifest.markBackedUp( 16 15 uuid: "test-uuid", 17 16 s3Key: "originals/2024/01/test-uuid.heic", 18 17 checksum: "sha256:abc123", 19 18 size: 1024, 20 - backedUpAt: "2024-01-15T12:00:00Z" 19 + backedUpAt: "2024-01-15T12:00:00Z", 21 20 ) 22 21 23 22 #expect(manifest.isBackedUp("test-uuid")) 24 23 #expect(!manifest.isBackedUp("other-uuid")) 25 24 26 - let entry = manifest.entries["test-uuid"]! 25 + let entry = try #require(manifest.entries["test-uuid"]) 27 26 #expect(entry.uuid == "test-uuid") 28 27 #expect(entry.s3Key == "originals/2024/01/test-uuid.heic") 29 28 #expect(entry.checksum == "sha256:abc123") ··· 31 30 #expect(entry.backedUpAt == "2024-01-15T12:00:00Z") 32 31 } 33 32 34 - @Test func markBackedUpDefaultsTimestamp() { 33 + @Test func markBackedUpDefaultsTimestamp() throws { 35 34 var manifest = Manifest() 36 35 manifest.markBackedUp( 37 36 uuid: "test-uuid", 38 37 s3Key: "originals/2024/01/test-uuid.heic", 39 - checksum: "sha256:abc123" 38 + checksum: "sha256:abc123", 40 39 ) 41 40 42 - let entry = manifest.entries["test-uuid"]! 41 + let entry = try #require(manifest.entries["test-uuid"]) 43 42 #expect(!entry.backedUpAt.isEmpty) 44 43 } 45 44 ··· 50 49 s3Key: "originals/2024/01/uuid-1.heic", 51 50 checksum: "sha256:aaa", 52 51 size: 500, 53 - backedUpAt: "2024-01-01T00:00:00Z" 52 + backedUpAt: "2024-01-01T00:00:00Z", 54 53 ) 55 54 manifest.markBackedUp( 56 55 uuid: "uuid-2", 57 56 s3Key: "originals/2024/02/uuid-2.jpg", 58 57 checksum: "sha256:bbb", 59 - backedUpAt: "2024-02-01T00:00:00Z" 58 + backedUpAt: "2024-02-01T00:00:00Z", 60 59 ) 61 60 62 61 let data = try manifest.encoded()
+8 -9
Tests/AtticCoreTests/MetadataBuilderTests.swift
··· 1 - import Testing 1 + @testable import AtticCore 2 2 import Foundation 3 3 import LadderKit 4 - @testable import AtticCore 4 + import Testing 5 5 6 - @Suite("MetadataBuilder") 7 6 struct MetadataBuilderTests { 8 - @Test func buildsMetadataFromAssetInfo() { 9 - let date = ISO8601DateFormatter().date(from: "2024-06-15T10:30:00Z")! 7 + @Test func buildsMetadataFromAssetInfo() throws { 8 + let date = try #require(ISO8601DateFormatter().date(from: "2024-06-15T10:30:00Z")) 10 9 let asset = AssetInfo( 11 10 identifier: "ABC-123/L0/001", 12 11 creationDate: date, ··· 22 21 albums: [AlbumInfo(identifier: "album-1", title: "Vacation")], 23 22 keywords: ["beach", "summer"], 24 23 people: [PersonInfo(uuid: "person-1", displayName: "Alice")], 25 - assetDescription: "A sunny beach photo" 24 + assetDescription: "A sunny beach photo", 26 25 ) 27 26 28 27 let metadata = buildMetadataJSON( 29 28 asset: asset, 30 29 s3Key: "originals/2024/06/ABC-123.heic", 31 30 checksum: "sha256:abc123", 32 - backedUpAt: "2024-06-15T12:00:00Z" 31 + backedUpAt: "2024-06-15T12:00:00Z", 33 32 ) 34 33 35 34 #expect(metadata.uuid == "ABC-123") ··· 61 60 pixelHeight: 1080, 62 61 latitude: nil, 63 62 longitude: nil, 64 - isFavorite: false 63 + isFavorite: false, 65 64 ) 66 65 67 66 let metadata = buildMetadataJSON( 68 67 asset: asset, 69 68 s3Key: "originals/unknown/00/XYZ-789.mov", 70 69 checksum: "sha256:def456", 71 - backedUpAt: "2024-01-01T00:00:00Z" 70 + backedUpAt: "2024-01-01T00:00:00Z", 72 71 ) 73 72 74 73 #expect(metadata.uuid == "XYZ-789")
+19 -11
Tests/AtticCoreTests/NetworkPauseTests.swift
··· 1 - import Testing 1 + @testable import AtticCore 2 2 import Foundation 3 3 import LadderKit 4 - @testable import AtticCore 4 + import Testing 5 5 6 6 // MARK: - Test helpers 7 7 ··· 23 23 24 24 func putObject(key: String, body: Data, contentType: String?) async throws { 25 25 putCallCount += 1 26 - if shouldFail && putCallCount > failAfterPuts { 26 + if shouldFail, putCallCount > failAfterPuts { 27 27 throw NetworkError.networkDown 28 28 } 29 29 try await inner.putObject(key: key, body: body, contentType: contentType) ··· 31 31 32 32 func putObject(key: String, fileURL: URL, contentType: String?) async throws { 33 33 putCallCount += 1 34 - if shouldFail && putCallCount > failAfterPuts { 34 + if shouldFail, putCallCount > failAfterPuts { 35 35 throw NetworkError.networkDown 36 36 } 37 37 try await inner.putObject(key: key, fileURL: fileURL, contentType: contentType) ··· 64 64 /// Network monitor that always reports unavailable and always times out. 65 65 /// Used for testing the timeout path without any polling or actor overhead. 66 66 struct AlwaysUnavailableNetworkMonitor: NetworkMonitoring { 67 - var isNetworkAvailable: Bool { false } 67 + var isNetworkAvailable: Bool { 68 + false 69 + } 68 70 69 71 func waitForNetwork(timeout: Duration) async throws -> Bool { 70 72 try Task.checkCancellation() ··· 89 91 func backupStarted(pending: Int, photos: Int, videos: Int) { 90 92 record("started(\(pending))") 91 93 } 94 + 92 95 func batchStarted(batchNumber: Int, totalBatches: Int, assetCount: Int) { 93 96 record("batch(\(batchNumber))") 94 97 } 98 + 95 99 func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) { 96 100 record("uploaded(\(uuid))") 97 101 } 102 + 98 103 func assetFailed(uuid: String, filename: String, message: String) { 99 104 record("failed(\(uuid))") 100 105 } 106 + 101 107 func manifestSaved(entriesCount: Int) { 102 108 record("manifestSaved(\(entriesCount))") 103 109 } 110 + 104 111 func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) { 105 112 record("completed(\(uploaded),\(failed))") 106 113 } 114 + 107 115 func backupPaused(reason: String) { 108 116 record("paused") 109 117 } 118 + 110 119 func backupResumed() { 111 120 record("resumed") 112 121 } ··· 114 123 115 124 // MARK: - Tests 116 125 117 - @Suite("NetworkPause") 118 126 struct NetworkPauseTests { 119 127 @Test func backupCompletesNormallyWithoutNetworkMonitor() async throws { 120 128 let assets = [makeTestAsset(uuid: "uuid-1")] ··· 130 138 manifestStore: manifestStore, 131 139 exporter: exporter, 132 140 s3: s3, 133 - options: BackupOptions(batchSize: 10) 141 + options: BackupOptions(batchSize: 10), 134 142 ) 135 143 136 144 #expect(report.uploaded == 1) ··· 153 161 exporter: exporter, 154 162 s3: s3, 155 163 options: BackupOptions(batchSize: 10), 156 - networkMonitor: monitor 164 + networkMonitor: monitor, 157 165 ) 158 166 159 167 #expect(report.uploaded == 1) ··· 189 197 s3: s3, 190 198 options: BackupOptions(batchSize: 10), 191 199 progress: progress, 192 - networkMonitor: monitor 200 + networkMonitor: monitor, 193 201 ) 194 202 195 203 #expect(report.uploaded == 1) ··· 226 234 s3: failingS3, 227 235 options: BackupOptions(batchSize: 10, networkTimeout: .milliseconds(100)), 228 236 progress: progress, 229 - networkMonitor: monitor 237 + networkMonitor: monitor, 230 238 ) 231 239 232 240 #expect(progress.events.contains("paused")) ··· 254 262 exporter: exporter, 255 263 s3: s3, 256 264 options: BackupOptions(batchSize: 10, networkTimeout: .seconds(30)), 257 - networkMonitor: monitor 265 + networkMonitor: monitor, 258 266 ) 259 267 } 260 268
+7 -8
Tests/AtticCoreTests/RebuildManifestTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 1 3 import Testing 2 - import Foundation 3 - @testable import AtticCore 4 4 5 - @Suite("RebuildManifest") 6 5 struct RebuildManifestTests { 7 6 @Test func rebuildsManifestFromMetadataFiles() async throws { 8 7 let s3 = MockS3Provider() ··· 28 27 try await s3.putObject( 29 28 key: "metadata/assets/uuid-1.json", 30 29 body: Data(metaJSON.utf8), 31 - contentType: "application/json" 30 + contentType: "application/json", 32 31 ) 33 32 34 33 let (manifest, report) = try await runRebuildManifest(s3: s3, manifestStore: store) ··· 46 45 47 46 try await s3.putObject( 48 47 key: "metadata/assets/readme.txt", 49 - body: Data("not json".utf8) 48 + body: Data("not json".utf8), 50 49 ) 51 50 52 51 let (manifest, report) = try await runRebuildManifest(s3: s3, manifestStore: store) ··· 62 61 63 62 try await s3.putObject( 64 63 key: "metadata/assets/broken.json", 65 - body: Data("not valid json".utf8) 64 + body: Data("not valid json".utf8), 66 65 ) 67 66 68 67 let (manifest, report) = try await runRebuildManifest(s3: s3, manifestStore: store) ··· 85 84 """ 86 85 try await s3.putObject( 87 86 key: "metadata/assets/evil.json", 88 - body: Data(metaJSON.utf8) 87 + body: Data(metaJSON.utf8), 89 88 ) 90 89 91 90 let (manifest, report) = try await runRebuildManifest(s3: s3, manifestStore: store) ··· 109 108 """ 110 109 try await s3.putObject( 111 110 key: "metadata/assets/uuid-1.json", 112 - body: Data(metaJSON.utf8) 111 + body: Data(metaJSON.utf8), 113 112 ) 114 113 115 114 _ = try await runRebuildManifest(s3: s3, manifestStore: store)
+8 -9
Tests/AtticCoreTests/RefreshMetadataTests.swift
··· 1 - import Testing 1 + @testable import AtticCore 2 2 import Foundation 3 3 import LadderKit 4 - @testable import AtticCore 4 + import Testing 5 5 6 - @Suite("RefreshMetadata") 7 6 struct RefreshMetadataTests { 8 7 @Test func refreshesMetadataForBackedUpAssets() async throws { 9 8 let s3 = MockS3Provider() ··· 12 11 uuid: "uuid-1", 13 12 s3Key: "originals/2024/01/uuid-1.heic", 14 13 checksum: "sha256:abc", 15 - backedUpAt: "2024-01-15T00:00:00Z" 14 + backedUpAt: "2024-01-15T00:00:00Z", 16 15 ) 17 16 18 17 let assets = [makeTestAsset(uuid: "uuid-1")] 19 18 20 19 let report = try await runRefreshMetadata( 21 - assets: assets, manifest: manifest, s3: s3 20 + assets: assets, manifest: manifest, s3: s3, 22 21 ) 23 22 24 23 #expect(report.updated == 1) ··· 38 37 let assets = [makeTestAsset(uuid: "uuid-1")] 39 38 40 39 let report = try await runRefreshMetadata( 41 - assets: assets, manifest: manifest, s3: s3 40 + assets: assets, manifest: manifest, s3: s3, 42 41 ) 43 42 44 43 #expect(report.updated == 0) ··· 52 51 manifest.markBackedUp( 53 52 uuid: "uuid-1", 54 53 s3Key: "originals/2024/01/uuid-1.heic", 55 - checksum: "sha256:abc" 54 + checksum: "sha256:abc", 56 55 ) 57 56 58 57 let assets = [makeTestAsset(uuid: "uuid-1")] 59 58 let options = RefreshMetadataOptions(dryRun: true) 60 59 61 60 let report = try await runRefreshMetadata( 62 - assets: assets, manifest: manifest, s3: s3, options: options 61 + assets: assets, manifest: manifest, s3: s3, options: options, 63 62 ) 64 63 65 64 #expect(report.skipped == 1) ··· 73 72 let manifest = Manifest() 74 73 75 74 let report = try await runRefreshMetadata( 76 - assets: [], manifest: manifest, s3: s3 75 + assets: [], manifest: manifest, s3: s3, 77 76 ) 78 77 79 78 #expect(report.updated == 0)
+10 -7
Tests/AtticCoreTests/RetryPolicyTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 1 3 import Testing 2 - import Foundation 3 - @testable import AtticCore 4 4 5 - @Suite("RetryPolicy") 6 5 struct RetryPolicyTests { 7 6 @Test func returnsResultOnFirstSuccess() async throws { 8 7 let result = try await withRetry { 42 } ··· 38 37 do { 39 38 let _: Int = try await withRetry( 40 39 maxAttempts: 3, 41 - baseDelay: .milliseconds(10) 40 + baseDelay: .milliseconds(10), 42 41 ) { 43 42 await counter.increment() 44 43 throw TransientError("ECONNRESET") ··· 54 53 let task = Task { 55 54 try await withRetry( 56 55 maxAttempts: 5, 57 - baseDelay: .milliseconds(100) 56 + baseDelay: .milliseconds(100), 58 57 ) { 59 58 await counter.increment() 60 59 throw TransientError("timeout") ··· 79 78 80 79 private struct TransientError: Error, CustomStringConvertible { 81 80 let description: String 82 - init(_ description: String) { self.description = description } 81 + init(_ description: String) { 82 + self.description = description 83 + } 83 84 } 84 85 85 86 private struct NonTransientError: Error, CustomStringConvertible { 86 87 let description: String 87 - init(_ description: String) { self.description = description } 88 + init(_ description: String) { 89 + self.description = description 90 + } 88 91 } 89 92 90 93 private actor Counter {
+24 -18
Tests/AtticCoreTests/S3ManifestStoreTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 1 3 import Testing 2 - import Foundation 3 - @testable import AtticCore 4 4 5 - @Suite("MockS3Provider") 6 5 struct MockS3ProviderTests { 7 6 @Test func putAndGetRoundTrip() async throws { 8 7 let s3 = MockS3Provider() ··· 72 71 73 72 func putObject(key: String, body: Data, contentType: String?) async throws {} 74 73 func putObject(key: String, fileURL: URL, contentType: String?) async throws {} 75 - func getObject(key: String) async throws -> Data { throw error } 76 - func headObject(key: String) async throws -> S3ObjectMeta? { nil } 77 - func listObjects(prefix: String) async throws -> [S3ListObject] { [] } 74 + func getObject(key: String) async throws -> Data { 75 + throw error 76 + } 77 + 78 + func headObject(key: String) async throws -> S3ObjectMeta? { 79 + nil 80 + } 81 + 82 + func listObjects(prefix: String) async throws -> [S3ListObject] { 83 + [] 84 + } 78 85 } 79 86 80 - @Suite("S3ManifestStore") 81 87 struct S3ManifestStoreTests { 82 88 @Test func loadReturnsEmptyWhenKeyMissing() async throws { 83 89 let s3 = MockS3Provider() ··· 95 101 96 102 @Test func loadReturnsEmptyOnS3NoSuchKey() async throws { 97 103 let s3 = ThrowingS3Provider( 98 - error: S3ClientError.s3Error(code: "NoSuchKey", message: "Not found")) 104 + error: S3ClientError.s3Error(code: "NoSuchKey", message: "Not found"), 105 + ) 99 106 let store = S3ManifestStore(s3: s3) 100 107 let manifest = try await store.load() 101 108 #expect(manifest.entries.isEmpty) ··· 117 124 uuid: "uuid-1", 118 125 s3Key: "originals/2024/01/uuid-1.heic", 119 126 checksum: "sha256:abc", 120 - backedUpAt: "2024-01-15T00:00:00Z" 127 + backedUpAt: "2024-01-15T00:00:00Z", 121 128 ) 122 129 try await store.save(manifest) 123 130 ··· 136 143 } 137 144 } 138 145 139 - @Suite("Manifest migration") 140 146 struct ManifestMigrationTests { 141 147 @Test func usesS3ManifestWhenPresent() async throws { 142 148 let s3 = MockS3Provider() ··· 146 152 uuid: "s3-uuid", 147 153 s3Key: "originals/2024/01/s3.heic", 148 154 checksum: "sha256:s3", 149 - backedUpAt: "2024-01-15T00:00:00Z" 155 + backedUpAt: "2024-01-15T00:00:00Z", 150 156 ) 151 157 try await store.save(existing) 152 158 153 159 let manifest = try await loadManifestWithMigration( 154 160 s3Store: store, 155 - localDirectory: URL(fileURLWithPath: "/nonexistent") 161 + localDirectory: URL(fileURLWithPath: "/nonexistent"), 156 162 ) 157 163 #expect(manifest.isBackedUp("s3-uuid")) 158 164 } ··· 179 185 try localJSON.write( 180 186 to: dir.appendingPathComponent("manifest.json"), 181 187 atomically: true, 182 - encoding: .utf8 188 + encoding: .utf8, 183 189 ) 184 190 185 191 let s3 = MockS3Provider() ··· 187 193 188 194 let manifest = try await loadManifestWithMigration( 189 195 s3Store: store, 190 - localDirectory: dir 196 + localDirectory: dir, 191 197 ) 192 198 #expect(manifest.isBackedUp("local-uuid")) 193 199 ··· 201 207 let store = S3ManifestStore(s3: s3) 202 208 let manifest = try await loadManifestWithMigration( 203 209 s3Store: store, 204 - localDirectory: URL(fileURLWithPath: "/nonexistent") 210 + localDirectory: URL(fileURLWithPath: "/nonexistent"), 205 211 ) 206 212 #expect(manifest.entries.isEmpty) 207 213 } ··· 228 234 try localJSON.write( 229 235 to: dir.appendingPathComponent("manifest.json"), 230 236 atomically: true, 231 - encoding: .utf8 237 + encoding: .utf8, 232 238 ) 233 239 234 240 // S3 has a different entry ··· 239 245 uuid: "s3-uuid", 240 246 s3Key: "originals/2024/01/s3.heic", 241 247 checksum: "sha256:s3", 242 - backedUpAt: "2024-01-15T00:00:00Z" 248 + backedUpAt: "2024-01-15T00:00:00Z", 243 249 ) 244 250 try await store.save(s3Manifest) 245 251 246 252 // S3 should win — local is not consulted 247 253 let manifest = try await loadManifestWithMigration( 248 254 s3Store: store, 249 - localDirectory: dir 255 + localDirectory: dir, 250 256 ) 251 257 #expect(manifest.isBackedUp("s3-uuid")) 252 258 #expect(!manifest.isBackedUp("local-uuid"))
+8 -9
Tests/AtticCoreTests/S3PathsTests.swift
··· 1 - import Testing 2 - import Foundation 3 1 @testable import AtticCore 2 + import Foundation 3 + import Testing 4 4 5 - @Suite("S3Paths") 6 5 struct S3PathsTests { 7 6 @Test func originalKeyGeneratesCorrectPath() throws { 8 - let date = ISO8601DateFormatter().date(from: "2024-01-15T12:00:00Z")! 7 + let date = try #require(ISO8601DateFormatter().date(from: "2024-01-15T12:00:00Z")) 9 8 let key = try S3Paths.originalKey(uuid: "abc-uuid", dateCreated: date, extension: "heic") 10 9 #expect(key == "originals/2024/01/abc-uuid.heic") 11 10 } ··· 16 15 } 17 16 18 17 @Test func originalKeyStripsLeadingDot() throws { 19 - let date = ISO8601DateFormatter().date(from: "2024-03-01T00:00:00Z")! 18 + let date = try #require(ISO8601DateFormatter().date(from: "2024-03-01T00:00:00Z")) 20 19 let key = try S3Paths.originalKey(uuid: "x", dateCreated: date, extension: ".HEIC") 21 20 #expect(key == "originals/2024/03/x.heic") 22 21 } 23 22 24 - @Test func originalKeyRejectsUnsafeUUID() { 25 - let date = ISO8601DateFormatter().date(from: "2024-01-15T12:00:00Z")! 23 + @Test func originalKeyRejectsUnsafeUUID() throws { 24 + let date = try #require(ISO8601DateFormatter().date(from: "2024-01-15T12:00:00Z")) 26 25 #expect(throws: S3PathError.self) { 27 26 try S3Paths.originalKey(uuid: "../../../etc", dateCreated: date, extension: "heic") 28 27 } ··· 31 30 } 32 31 } 33 32 34 - @Test func originalKeyRejectsUnsafeExtension() { 35 - let date = ISO8601DateFormatter().date(from: "2024-01-15T12:00:00Z")! 33 + @Test func originalKeyRejectsUnsafeExtension() throws { 34 + let date = try #require(ISO8601DateFormatter().date(from: "2024-01-15T12:00:00Z")) 36 35 #expect(throws: S3PathError.self) { 37 36 try S3Paths.originalKey(uuid: "abc", dateCreated: date, extension: "h/e") 38 37 }
+3 -4
Tests/AtticCoreTests/S3XMLParsingTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 1 3 import Testing 2 - import Foundation 3 - @testable import AtticCore 4 4 5 - @Suite("S3XMLParsing") 6 5 struct S3XMLParsingTests { 7 6 // MARK: - ListObjectsV2 8 7 ··· 27 26 28 27 #expect(result.objects.count == 2) 29 28 #expect(result.objects[0].key == "originals/2024/01/abc.heic") 30 - #expect(result.objects[0].size == 1234567) 29 + #expect(result.objects[0].size == 1_234_567) 31 30 #expect(result.objects[1].key == "originals/2024/02/def.jpg") 32 31 #expect(result.objects[1].size == 89012) 33 32 #expect(result.isTruncated == false)
+10 -5
Tests/AtticCoreTests/VerifyPipelineTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 1 3 import Testing 2 - import Foundation 3 - @testable import AtticCore 4 4 5 - @Suite("VerifyPipeline") 6 5 struct VerifyPipelineTests { 7 6 private func makeManifest(entries: [(uuid: String, s3Key: String, checksum: String)]) -> Manifest { 8 7 var manifest = Manifest() ··· 75 74 /// S3 provider that throws on headObject to test the error path. 76 75 private actor FailingS3Provider: S3Providing { 77 76 func putObject(key: String, body: Data, contentType: String?) async throws {} 78 - func getObject(key: String) async throws -> Data { Data() } 77 + func getObject(key: String) async throws -> Data { 78 + Data() 79 + } 80 + 79 81 func headObject(key: String) async throws -> S3ObjectMeta? { 80 82 throw FailingS3Error.networkError 81 83 } 82 - func listObjects(prefix: String) async throws -> [S3ListObject] { [] } 84 + 85 + func listObjects(prefix: String) async throws -> [S3ListObject] { 86 + [] 87 + } 83 88 } 84 89 85 90 private enum FailingS3Error: Error {