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.

feat(status): Replace scan with rich status dashboard

Merge scan into status as a sectioned dashboard showing library stats,
backup progress, S3 manifest info, and UTI breakdown. Supports ANSI
rich output for TTY and plain text for piped/CI. Remove scan command.

+508 -82
+1 -2
CLAUDE.md
··· 58 58 59 59 | Command | Description | 60 60 |---------|-------------| 61 - | `scan` | Scan Photos library, show summary | 62 - | `status` | Show backup progress vs manifest | 61 + | `status` | Library stats, backup progress, S3 manifest info | 63 62 | `backup` | Back up photos/videos to S3 | 64 63 | `verify` | Verify S3 objects against manifest | 65 64 | `refresh-metadata` | Re-upload metadata JSON |
-1
Sources/AtticCLI/AtticCLI.swift
··· 8 8 abstract: "Back up iCloud Photos to S3-compatible storage.", 9 9 version: AtticCore.version, 10 10 subcommands: [ 11 - ScanCommand.self, 12 11 StatusCommand.self, 13 12 BackupCommand.self, 14 13 VerifyCommand.self,
-49
Sources/AtticCLI/ScanCommand.swift
··· 1 - import ArgumentParser 2 - import AtticCore 3 - import LadderKit 4 - 5 - struct ScanCommand: AsyncParsableCommand { 6 - static let configuration = CommandConfiguration( 7 - commandName: "scan", 8 - abstract: "Scan your Photos library and show a summary.", 9 - ) 10 - 11 - func run() async throws { 12 - let assets = Dependencies.loadAssets() 13 - 14 - if assets.isEmpty { 15 - print("No assets found in Photos library.") 16 - return 17 - } 18 - 19 - let photos = assets.filter { $0.kind == .photo } 20 - let videos = assets.filter { $0.kind == .video } 21 - 22 - print("Photos Library Scan") 23 - print("===================") 24 - print("Total assets: \(assets.count)") 25 - print(" Photos: \(photos.count)") 26 - print(" Videos: \(videos.count)") 27 - 28 - // Group by UTI 29 - var utiCounts: [String: Int] = [:] 30 - for asset in assets { 31 - let uti = asset.uniformTypeIdentifier ?? "unknown" 32 - utiCounts[uti, default: 0] += 1 33 - } 34 - let topUTIs = utiCounts.sorted { $0.value > $1.value }.prefix(10) 35 - 36 - print("") 37 - print("Top file types:") 38 - for (uti, count) in topUTIs { 39 - print(" \(uti): \(count)") 40 - } 41 - 42 - // Favorites + edited 43 - let favorites = assets.filter(\.isFavorite).count 44 - let edited = assets.filter(\.hasEdit).count 45 - print("") 46 - print("Favorites: \(favorites)") 47 - print("Edited: \(edited)") 48 - } 49 - }
+24 -30
Sources/AtticCLI/StatusCommand.swift
··· 1 1 import ArgumentParser 2 2 import AtticCore 3 + import Foundation 3 4 import LadderKit 4 5 5 6 struct StatusCommand: AsyncParsableCommand { 6 7 static let configuration = CommandConfiguration( 7 8 commandName: "status", 8 - abstract: "Show backup progress — how many assets are backed up vs pending.", 9 + abstract: "Show library stats, backup progress, and S3 manifest info.", 9 10 ) 10 11 11 12 func run() async throws { 12 - let (config, _, manifestStore) = try Dependencies.makeBackupDeps() 13 - let manifest = try await Dependencies.loadManifest(store: manifestStore) 13 + let isTTY = isatty(STDOUT_FILENO) != 0 14 14 let assets = Dependencies.loadAssets() 15 15 16 - // Single-pass counting 17 - var backedUpPhotos = 0, backedUpVideos = 0, pendingPhotos = 0, pendingVideos = 0 18 - var backedUpBytes = 0 19 - for asset in assets { 20 - if manifest.isBackedUp(asset.uuid) { 21 - if asset.kind == .photo { backedUpPhotos += 1 } else { backedUpVideos += 1 } 22 - backedUpBytes += manifest.entries[asset.uuid]?.size ?? 0 23 - } else { 24 - if asset.kind == .photo { pendingPhotos += 1 } else { pendingVideos += 1 } 25 - } 16 + let library = StatusStats.computeLibraryStats(assets) 17 + let types = StatusStats.computeUTIBreakdown(assets) 18 + 19 + var backup: BackupStats? 20 + var s3: S3Info? 21 + 22 + do { 23 + let (config, _, manifestStore) = try Dependencies.makeBackupDeps() 24 + let manifest = try await Dependencies.loadManifest(store: manifestStore) 25 + backup = StatusStats.computeBackupStats(assets: assets, manifest: manifest) 26 + s3 = StatusStats.computeS3Info(bucket: config.bucket, manifest: manifest) 27 + } catch is CLIError { 28 + // No config — show library only with init hint 26 29 } 27 - let backedUpCount = backedUpPhotos + backedUpVideos 28 - let pendingCount = pendingPhotos + pendingVideos 29 30 30 - let pct = assets.isEmpty ? 100.0 : Double(backedUpCount) / Double(assets.count) * 100 31 + let data = DashboardData( 32 + version: AtticCore.version, 33 + library: library, 34 + backup: backup, 35 + s3: s3, 36 + types: types, 37 + ) 31 38 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") 39 + StatusRenderer(isTTY: isTTY).render(data) 46 40 } 47 41 }
+141
Sources/AtticCLI/StatusRenderer.swift
··· 1 + import AtticCore 2 + import Foundation 3 + 4 + struct StatusRenderer { 5 + let isTTY: Bool 6 + 7 + func render(_ data: DashboardData) { 8 + if isTTY { 9 + renderRich(data) 10 + } else { 11 + renderPlain(data) 12 + } 13 + } 14 + 15 + // MARK: - Rich (ANSI) Output 16 + 17 + private func renderRich(_ data: DashboardData) { 18 + let bold = "\u{1b}[1m" 19 + let reset = "\u{1b}[0m" 20 + let dim = "\u{1b}[2m" 21 + 22 + print("\(bold)Attic v\(data.version)\(reset)") 23 + print(dim + String(repeating: "\u{2500}", count: 38) + reset) 24 + 25 + // Library section 26 + print("") 27 + print("\(bold)Library\(reset)") 28 + printTwoColumn("Photos", format(data.library.photos), "Videos", format(data.library.videos)) 29 + printTwoColumn("Favorites", format(data.library.favorites), "Edited", format(data.library.edited)) 30 + 31 + // Backup section 32 + if let backup = data.backup { 33 + print("") 34 + print("\(bold)Backup\(reset)") 35 + let bar = progressBar(percentage: backup.percentage, width: 28) 36 + let pctColor = colorForPercentage(backup.percentage) 37 + print(" \(bar) \(pctColor)\(String(format: "%.1f", backup.percentage))%\(reset)") 38 + print(" Backed up \(padLeft(format(backup.backedUp), width: 8)) (\(formatBytes(backup.backedUpBytes)))") 39 + print(" Pending \(padLeft(format(backup.pending), width: 8))") 40 + } 41 + 42 + // S3 section 43 + if let s3 = data.s3 { 44 + print("") 45 + print("\(bold)S3\(reset) \(dim)- \(s3.bucket)\(reset)") 46 + print(" Manifest \(padLeft(format(s3.manifestEntries), width: 8)) entries") 47 + if let lastBackup = s3.lastBackup { 48 + print(" Last backup \(lastBackup)") 49 + } 50 + } 51 + 52 + // No config hint 53 + if data.backup == nil { 54 + print("") 55 + print("\(dim)Run 'attic init' to configure S3 backup.\(reset)") 56 + } 57 + 58 + // Types section 59 + if !data.types.isEmpty { 60 + print("") 61 + let typeParts = data.types.map { "\($0.uti) \(Int(round($0.percentage)))%" } 62 + print("\(bold)Types:\(reset) \(typeParts.joined(separator: " \(dim)-\(reset) "))") 63 + } 64 + } 65 + 66 + // MARK: - Plain Text Output 67 + 68 + private func renderPlain(_ data: DashboardData) { 69 + print("Attic v\(data.version)") 70 + print(String(repeating: "-", count: 38)) 71 + 72 + print("") 73 + print("Library") 74 + print(" Photos: \(format(data.library.photos))") 75 + print(" Videos: \(format(data.library.videos))") 76 + print(" Favorites: \(format(data.library.favorites))") 77 + print(" Edited: \(format(data.library.edited))") 78 + 79 + if let backup = data.backup { 80 + print("") 81 + print("Backup") 82 + print(" Progress: \(String(format: "%.1f", backup.percentage))%") 83 + print(" Backed up: \(format(backup.backedUp)) (\(formatBytes(backup.backedUpBytes)))") 84 + print(" Pending: \(format(backup.pending))") 85 + } 86 + 87 + if let s3 = data.s3 { 88 + print("") 89 + print("S3 - \(s3.bucket)") 90 + print(" Manifest: \(format(s3.manifestEntries)) entries") 91 + if let lastBackup = s3.lastBackup { 92 + print(" Last backup: \(lastBackup)") 93 + } 94 + } 95 + 96 + if data.backup == nil { 97 + print("") 98 + print("Run 'attic init' to configure S3 backup.") 99 + } 100 + 101 + if !data.types.isEmpty { 102 + let typeParts = data.types.map { "\($0.uti) \(Int(round($0.percentage)))%" } 103 + print("") 104 + print("Types: \(typeParts.joined(separator: " - "))") 105 + } 106 + } 107 + 108 + // MARK: - Helpers 109 + 110 + private func progressBar(percentage: Double, width: Int) -> String { 111 + let filled = Int(Double(width) * min(percentage, 100) / 100) 112 + let empty = width - filled 113 + return "[\(String(repeating: "#", count: filled))\(String(repeating: "-", count: empty))]" 114 + } 115 + 116 + private func colorForPercentage(_ pct: Double) -> String { 117 + if pct >= 80 { return "\u{1b}[32m" } // green 118 + if pct >= 40 { return "\u{1b}[33m" } // yellow 119 + return "\u{1b}[31m" // red 120 + } 121 + 122 + private func printTwoColumn(_ label1: String, _ value1: String, _ label2: String, _ value2: String) { 123 + let col1 = " \(label1.padding(toLength: 10, withPad: " ", startingAt: 0)) \(padLeft(value1, width: 6))" 124 + let col2 = " \(label2.padding(toLength: 10, withPad: " ", startingAt: 0)) \(padLeft(value2, width: 6))" 125 + print("\(col1)\(col2)") 126 + } 127 + 128 + private func padLeft(_ string: String, width: Int) -> String { 129 + let padding = max(0, width - string.count) 130 + return String(repeating: " ", count: padding) + string 131 + } 132 + } 133 + 134 + // MARK: - Number Formatting 135 + 136 + private func format(_ number: Int) -> String { 137 + let formatter = NumberFormatter() 138 + formatter.numberStyle = .decimal 139 + formatter.groupingSeparator = "," 140 + return formatter.string(from: NSNumber(value: number)) ?? "\(number)" 141 + }
+153
Sources/AtticCore/StatusStats.swift
··· 1 + import Foundation 2 + import LadderKit 3 + 4 + // MARK: - Dashboard Data Models 5 + 6 + public struct DashboardData: Sendable { 7 + public let version: String 8 + public let library: LibraryStats 9 + public let backup: BackupStats? 10 + public let s3: S3Info? 11 + public let types: [(uti: String, percentage: Double)] 12 + 13 + public init( 14 + version: String, 15 + library: LibraryStats, 16 + backup: BackupStats?, 17 + s3: S3Info?, 18 + types: [(uti: String, percentage: Double)], 19 + ) { 20 + self.version = version 21 + self.library = library 22 + self.backup = backup 23 + self.s3 = s3 24 + self.types = types 25 + } 26 + } 27 + 28 + public struct LibraryStats: Sendable, Equatable { 29 + public let photos: Int 30 + public let videos: Int 31 + public let favorites: Int 32 + public let edited: Int 33 + 34 + public var total: Int { 35 + photos + videos 36 + } 37 + 38 + public init(photos: Int, videos: Int, favorites: Int, edited: Int) { 39 + self.photos = photos 40 + self.videos = videos 41 + self.favorites = favorites 42 + self.edited = edited 43 + } 44 + } 45 + 46 + public struct BackupStats: Sendable, Equatable { 47 + public let backedUp: Int 48 + public let backedUpBytes: Int 49 + public let pending: Int 50 + public let total: Int 51 + 52 + public var percentage: Double { 53 + total == 0 ? 100.0 : Double(backedUp) / Double(total) * 100 54 + } 55 + 56 + public init(backedUp: Int, backedUpBytes: Int, pending: Int, total: Int) { 57 + self.backedUp = backedUp 58 + self.backedUpBytes = backedUpBytes 59 + self.pending = pending 60 + self.total = total 61 + } 62 + } 63 + 64 + public struct S3Info: Sendable, Equatable { 65 + public let bucket: String 66 + public let manifestEntries: Int 67 + public let lastBackup: String? 68 + 69 + public init(bucket: String, manifestEntries: Int, lastBackup: String?) { 70 + self.bucket = bucket 71 + self.manifestEntries = manifestEntries 72 + self.lastBackup = lastBackup 73 + } 74 + } 75 + 76 + // MARK: - Stats Computation 77 + 78 + public enum StatusStats { 79 + public static func computeLibraryStats(_ assets: [AssetInfo]) -> LibraryStats { 80 + var photos = 0, videos = 0, favorites = 0, edited = 0 81 + for asset in assets { 82 + if asset.kind == .photo { photos += 1 } else { videos += 1 } 83 + if asset.isFavorite { favorites += 1 } 84 + if asset.hasEdit { edited += 1 } 85 + } 86 + return LibraryStats(photos: photos, videos: videos, favorites: favorites, edited: edited) 87 + } 88 + 89 + public static func computeUTIBreakdown( 90 + _ assets: [AssetInfo], 91 + topN: Int = 5, 92 + ) -> [(uti: String, percentage: Double)] { 93 + guard !assets.isEmpty else { return [] } 94 + var counts: [String: Int] = [:] 95 + for asset in assets { 96 + let uti = asset.uniformTypeIdentifier ?? "unknown" 97 + let display = uti.hasPrefix("public.") ? String(uti.dropFirst(7)).uppercased() : uti.uppercased() 98 + counts[display, default: 0] += 1 99 + } 100 + let sorted = counts.sorted { $0.value > $1.value } 101 + let total = Double(assets.count) 102 + var result: [(uti: String, percentage: Double)] = [] 103 + var shown = 0.0 104 + 105 + for (index, entry) in sorted.enumerated() { 106 + let pct = Double(entry.value) / total * 100 107 + if index < topN { 108 + result.append((uti: entry.key, percentage: pct)) 109 + shown += pct 110 + } else { 111 + break 112 + } 113 + } 114 + 115 + let otherPct = 100.0 - shown 116 + if otherPct > 0.5 { 117 + result.append((uti: "Other", percentage: otherPct)) 118 + } 119 + 120 + return result 121 + } 122 + 123 + public static func computeBackupStats(assets: [AssetInfo], manifest: Manifest) -> BackupStats { 124 + var backedUp = 0, backedUpBytes = 0 125 + for asset in assets { 126 + if manifest.isBackedUp(asset.uuid) { 127 + backedUp += 1 128 + backedUpBytes += manifest.entries[asset.uuid]?.size ?? 0 129 + } 130 + } 131 + return BackupStats( 132 + backedUp: backedUp, 133 + backedUpBytes: backedUpBytes, 134 + pending: assets.count - backedUp, 135 + total: assets.count, 136 + ) 137 + } 138 + 139 + public static func computeS3Info(bucket: String, manifest: Manifest) -> S3Info { 140 + let lastBackup = manifest.entries.values 141 + .map(\.backedUpAt) 142 + .max() 143 + let displayDate: String? = lastBackup.flatMap { iso in 144 + let trimmed = String(iso.prefix(10)) 145 + return trimmed.count == 10 ? trimmed : iso 146 + } 147 + return S3Info( 148 + bucket: bucket, 149 + manifestEntries: manifest.entries.count, 150 + lastBackup: displayDate, 151 + ) 152 + } 153 + }
+189
Tests/AtticCoreTests/StatusStatsTests.swift
··· 1 + @testable import AtticCore 2 + import LadderKit 3 + import Testing 4 + 5 + struct StatusStatsTests { 6 + // MARK: - Test Helpers 7 + 8 + private func makeAsset( 9 + uuid: String = "test-uuid", 10 + kind: AssetKind = .photo, 11 + uti: String? = "public.heic", 12 + isFavorite: Bool = false, 13 + hasEdit: Bool = false, 14 + ) -> AssetInfo { 15 + AssetInfo( 16 + identifier: "\(uuid)/L0/001", 17 + creationDate: nil, 18 + kind: kind, 19 + pixelWidth: 100, 20 + pixelHeight: 100, 21 + latitude: nil, 22 + longitude: nil, 23 + isFavorite: isFavorite, 24 + originalFilename: "IMG.HEIC", 25 + uniformTypeIdentifier: uti, 26 + hasEdit: hasEdit, 27 + ) 28 + } 29 + 30 + // MARK: - Library Stats 31 + 32 + @Test func libraryStatsCountsPhotosAndVideos() { 33 + let assets = [ 34 + makeAsset(uuid: "p1", kind: .photo), 35 + makeAsset(uuid: "p2", kind: .photo), 36 + makeAsset(uuid: "v1", kind: .video), 37 + ] 38 + let stats = StatusStats.computeLibraryStats(assets) 39 + #expect(stats.photos == 2) 40 + #expect(stats.videos == 1) 41 + #expect(stats.total == 3) 42 + } 43 + 44 + @Test func libraryStatsCountsFavoritesAndEdited() { 45 + let assets = [ 46 + makeAsset(uuid: "1", isFavorite: true, hasEdit: true), 47 + makeAsset(uuid: "2", isFavorite: true), 48 + makeAsset(uuid: "3", hasEdit: true), 49 + makeAsset(uuid: "4"), 50 + ] 51 + let stats = StatusStats.computeLibraryStats(assets) 52 + #expect(stats.favorites == 2) 53 + #expect(stats.edited == 2) 54 + } 55 + 56 + @Test func libraryStatsEmptyLibrary() { 57 + let stats = StatusStats.computeLibraryStats([]) 58 + #expect(stats == LibraryStats(photos: 0, videos: 0, favorites: 0, edited: 0)) 59 + } 60 + 61 + // MARK: - UTI Breakdown 62 + 63 + @Test func utiBreakdownTopTypes() { 64 + let assets = [ 65 + makeAsset(uuid: "1", uti: "public.heic"), 66 + makeAsset(uuid: "2", uti: "public.heic"), 67 + makeAsset(uuid: "3", uti: "public.heic"), 68 + makeAsset(uuid: "4", uti: "public.jpeg"), 69 + makeAsset(uuid: "5", uti: "public.png"), 70 + ] 71 + let breakdown = StatusStats.computeUTIBreakdown(assets) 72 + #expect(breakdown[0].uti == "HEIC") 73 + #expect(breakdown[0].percentage == 60.0) 74 + #expect(breakdown[1].uti == "JPEG") 75 + #expect(breakdown[1].percentage == 20.0) 76 + #expect(breakdown[2].uti == "PNG") 77 + #expect(breakdown[2].percentage == 20.0) 78 + } 79 + 80 + @Test func utiBreakdownGroupsOther() { 81 + // 6 types: top 5 shown, rest grouped as "Other" 82 + var assets: [AssetInfo] = [] 83 + let types = ["public.heic", "public.jpeg", "public.png", "public.mov", "public.mp4", "public.tiff"] 84 + for (i, uti) in types.enumerated() { 85 + for j in 0 ..< (10 - i) { 86 + assets.append(makeAsset(uuid: "\(uti)-\(j)", uti: uti)) 87 + } 88 + } 89 + let breakdown = StatusStats.computeUTIBreakdown(assets, topN: 5) 90 + #expect(breakdown.count == 6) 91 + #expect(breakdown.last?.uti == "Other") 92 + } 93 + 94 + @Test func utiBreakdownEmptyAssets() { 95 + let breakdown = StatusStats.computeUTIBreakdown([]) 96 + #expect(breakdown.isEmpty) 97 + } 98 + 99 + @Test func utiBreakdownStripsPublicPrefix() { 100 + let assets = [makeAsset(uuid: "1", uti: "public.heif")] 101 + let breakdown = StatusStats.computeUTIBreakdown(assets) 102 + #expect(breakdown[0].uti == "HEIF") 103 + } 104 + 105 + @Test func utiBreakdownHandlesUnknown() { 106 + let assets = [makeAsset(uuid: "1", uti: nil)] 107 + let breakdown = StatusStats.computeUTIBreakdown(assets) 108 + #expect(breakdown[0].uti == "UNKNOWN") 109 + } 110 + 111 + // MARK: - Backup Stats 112 + 113 + @Test func backupStatsEmptyManifest() { 114 + let assets = [makeAsset(uuid: "1"), makeAsset(uuid: "2")] 115 + let manifest = Manifest(entries: [:]) 116 + let stats = StatusStats.computeBackupStats(assets: assets, manifest: manifest) 117 + #expect(stats.backedUp == 0) 118 + #expect(stats.pending == 2) 119 + #expect(stats.percentage == 0.0) 120 + } 121 + 122 + @Test func backupStatsPartialBackup() { 123 + let assets = [makeAsset(uuid: "a"), makeAsset(uuid: "b"), makeAsset(uuid: "c")] 124 + let manifest = Manifest(entries: [ 125 + "a": ManifestEntry( 126 + uuid: "a", 127 + s3Key: "originals/a.heic", 128 + checksum: "abc", 129 + backedUpAt: "2026-01-01", 130 + size: 1024, 131 + ), 132 + ]) 133 + let stats = StatusStats.computeBackupStats(assets: assets, manifest: manifest) 134 + #expect(stats.backedUp == 1) 135 + #expect(stats.backedUpBytes == 1024) 136 + #expect(stats.pending == 2) 137 + #expect(stats.total == 3) 138 + } 139 + 140 + @Test func backupStatsFullBackup() { 141 + let assets = [makeAsset(uuid: "x")] 142 + let manifest = Manifest(entries: [ 143 + "x": ManifestEntry( 144 + uuid: "x", 145 + s3Key: "originals/x.heic", 146 + checksum: "abc", 147 + backedUpAt: "2026-01-01", 148 + size: 2048, 149 + ), 150 + ]) 151 + let stats = StatusStats.computeBackupStats(assets: assets, manifest: manifest) 152 + #expect(stats.backedUp == 1) 153 + #expect(stats.pending == 0) 154 + #expect(stats.percentage == 100.0) 155 + } 156 + 157 + @Test func backupStatsEmptyLibrary() { 158 + let stats = StatusStats.computeBackupStats(assets: [], manifest: Manifest()) 159 + #expect(stats.percentage == 100.0) 160 + #expect(stats.total == 0) 161 + } 162 + 163 + // MARK: - S3 Info 164 + 165 + @Test func s3InfoDeriesLastBackup() { 166 + let manifest = Manifest(entries: [ 167 + "a": ManifestEntry(uuid: "a", s3Key: "k", checksum: "c", backedUpAt: "2026-01-15T10:00:00Z"), 168 + "b": ManifestEntry(uuid: "b", s3Key: "k", checksum: "c", backedUpAt: "2026-04-03T14:22:00Z"), 169 + "c": ManifestEntry(uuid: "c", s3Key: "k", checksum: "c", backedUpAt: "2026-02-20T08:00:00Z"), 170 + ]) 171 + let info = StatusStats.computeS3Info(bucket: "my-bucket", manifest: manifest) 172 + #expect(info.bucket == "my-bucket") 173 + #expect(info.manifestEntries == 3) 174 + #expect(info.lastBackup == "2026-04-03") 175 + } 176 + 177 + @Test func s3InfoEmptyManifest() { 178 + let info = StatusStats.computeS3Info(bucket: "b", manifest: Manifest()) 179 + #expect(info.manifestEntries == 0) 180 + #expect(info.lastBackup == nil) 181 + } 182 + 183 + // MARK: - BackupStats percentage 184 + 185 + @Test func backupPercentageAt50() { 186 + let stats = BackupStats(backedUp: 5, backedUpBytes: 0, pending: 5, total: 10) 187 + #expect(stats.percentage == 50.0) 188 + } 189 + }